Regression / regression (push) Canceled after 0s
## 品牌标识(本次会话) 起因:品牌此前没有任何图形标识 —— 唯一 favicon 是内联 data-URI 里的字母「A」, 那是 v2.0.0「Aurora Admin → Kole UI」改名漏掉的一处(PC 顶栏也是「A」, 移动端站已是「K」;移动端文档站则完全没有 favicon)。 - 几何:24 网格三个互不接触的笔画(竖 + 两斜),圆头描边; 描边 2.25 → 16px 标签页尺寸下正好 1.5px = 规范原文「描边1.5px」 - 取色分两套(刻意):favicon 硬编码品牌蓝/白(渲染在浏览器标签栏,不继承 kole-dark); 顶栏标记走 currentColor(实测暗色下自动转 rgb(20,22,28)) - 新增 theme-color 双条(light #FFFFFF / dark #1C1F26,取 --kole-color-card-bg) - 修 site/app.js hero 标语 KOLE ADMIN → KOLE UI(改名变形残留) - 移动端 7 个模板补 favicon(此前计数 0) 验收:门禁 9 条全 OK(site-routing/site-routes/mobile-docs/mobile-site/isolation/ theme/nav/i18n/icons);PC 回归 1464/1464 · 移动端 807/807,各连跑 8 次一致; 两端 favicon 405 字节逐字节一致;PC 站控制台错误 1→0。 ## 并行会话成果(本次一并入库) - 图标系统:2576 图标(TDesign/Element Plus,MIT)+ 11 端注入 + 5 个构建门禁工具 + IconPreview 预览页 + ICON-SPEC.md 冻结规格 - 移动端平台:47 组件 × 6 端 + 文档站 53 页 + 隔离门禁 - PC 组件:103 个大后台组件 / 组件11 批次 - uni-app:PC 端试点 + 移动端端实现 + 真实编译验证 ## 工程 - .gitignore 补 .scratch/ 与 .zcode-preexisting-*.txt(会话中间产物,实测 9.1MB,不入库) - CHANGELOG 补品牌标识条目 - ROADMAP 登 S8-P4(品牌标识任务包 + og:image/apple-touch-icon 未做部分)
704 lines
34 KiB
JavaScript
704 lines
34 KiB
JavaScript
#!/usr/bin/env node
|
||
/**
|
||
* verify-cross-platform.mjs — S2-P6 跨端一致性自动验证(方案 B:静态结构比对)
|
||
*
|
||
* 对索引中全部组件的 4 端实现(H5 / React-JSX / Vue2 / Vue3)提取 class 集合与
|
||
* 结构骨架(标签集合 + 元素数),做集合 diff,输出 tests/cross-platform-report.json。
|
||
*
|
||
* - 零运行时依赖(只读文件 + 正则,不渲染、不联网)。
|
||
* - 本脚本只负责「暴露差异」,发现差异 = 任务价值,不在本脚本内顺手修。
|
||
* - 退出码:0 = 报告成功生成;1 = 脚本自身失败(读不到数据、无输出、total 与索引不一致)。
|
||
*
|
||
* 用法:node tools/verify-cross-platform.mjs [--out tests/cross-platform-report.json]
|
||
*/
|
||
import { readFileSync, writeFileSync, mkdirSync } from 'fs';
|
||
import { fileURLToPath } from 'url';
|
||
import { dirname, join, basename } from 'path';
|
||
|
||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||
const ROOT = join(__dirname, '..');
|
||
const arg = (k) => {
|
||
const i = process.argv.indexOf(k);
|
||
return i > -1 ? process.argv[i + 1] : null;
|
||
};
|
||
const OUT = arg('--out') || join(ROOT, 'tests', 'cross-platform-report.json');
|
||
|
||
const data = JSON.parse(readFileSync(join(ROOT, 'site', 'data.json'), 'utf8'));
|
||
const components = data.components || [];
|
||
if (!components.length) {
|
||
console.error('[FATAL] site/data.json 无 components');
|
||
process.exit(1);
|
||
}
|
||
|
||
/* ---------- 提取器 ---------- */
|
||
|
||
function addTokens(set, text) {
|
||
(text || '').split(/\s+/).filter(Boolean).forEach((t) => set.add(t));
|
||
}
|
||
|
||
// H5:class="a b"
|
||
function classesFromHtml(src) {
|
||
const set = new Set();
|
||
const re = /class\s*=\s*"([^"]*)"/g;
|
||
let m;
|
||
while ((m = re.exec(src))) addTokens(set, m[1]);
|
||
// H5 演示页 DOM 由内联 <script> 动态渲染(如 Tree 的 `kole-tree-node${sel}`):
|
||
// 扫描 script 内模板字符串/引号字面量中的类名,与 JSX 同口径
|
||
const scripts = src.match(/<script[^>]*>([\s\S]*?)<\/script>/g) || [];
|
||
for (const block of scripts) {
|
||
// 反引号模板:`kole-tabs-tab${...}` → 头部静态类 + ${} 内三元分支引号类
|
||
// 头部有两种形态:裸类(`kole-tabs-tab${`)与 HTML 属性(`<div class="kole-tree-node${`),都要提
|
||
for (const tm of block.matchAll(/`([^`]*?)`/g)) {
|
||
const tpl = tm[1];
|
||
const head = tpl.split('${')[0];
|
||
// 形态1:HTML 属性 class="a b
|
||
for (const hm of head.matchAll(/class\s*=\s*"([^"]*)$/g)) {
|
||
hm[1].split(/\s+/).filter(Boolean).forEach((t) => {
|
||
if (/^[A-Za-z][\w-]*$/.test(t)) set.add(t);
|
||
});
|
||
}
|
||
// 形态2:裸类名
|
||
head.split(/\s+/).filter(Boolean).forEach((t) => {
|
||
if (/^[A-Za-z][\w-]*$/.test(t)) set.add(t);
|
||
});
|
||
for (const qm of tpl.matchAll(/'([^']*?)'/g)) {
|
||
qm[1].split(/\s+/).filter(Boolean).forEach((t) => {
|
||
if (/^[A-Za-z][\w-]*$/.test(t)) set.add(t);
|
||
});
|
||
}
|
||
// 双引号:只收 class="..." 内的,role="link" 等别的属性值不收
|
||
for (const cm of tpl.matchAll(/class\s*=\s*"([^"]*?)"/g)) {
|
||
cm[1].split(/\s+/).filter(Boolean).forEach((t) => {
|
||
if (/^[A-Za-z][\w-]*$/.test(t)) set.add(t);
|
||
});
|
||
}
|
||
}
|
||
// 普通引号 class 拼接:'<div class="kole-xxx' + x / "kole-xxx"
|
||
// 引号内是 HTML 片段(含空格/属性),不能要求闭合引号紧跟类名
|
||
for (const qm of block.matchAll(/'([^']*?)'/g)) {
|
||
// 取其中的 class="..." 属性
|
||
for (const cm of qm[1].matchAll(/class\s*=\s*"([^"]*)/g)) {
|
||
cm[1].split(/\s+/).filter(Boolean).forEach((t) => {
|
||
if (/^[A-Za-z][\w-]+$/.test(t)) set.add(t);
|
||
});
|
||
}
|
||
// 裸 kole-/is- 类(如 'kole-mention-item' + (...) 中的独立 token)
|
||
const inner = qm[1].trim();
|
||
if (/^(?:kole-|is-|has-|btn-|col-)[A-Za-z0-9_-]+$/.test(inner)) set.add(inner);
|
||
}
|
||
// className = 'kole-message is-' 这类赋值语句:引号内以空格结尾的类前缀也要收头部类
|
||
for (const am of block.matchAll(/(?:className|\.className)\s*=\s*'([^']*?)'/g)) {
|
||
am[1].split(/\s+/).filter(Boolean).forEach((t) => {
|
||
if (/^(?:kole-|is-|has-|btn-|col-)[A-Za-z0-9_-]+$/.test(t)) set.add(t);
|
||
});
|
||
}
|
||
for (const qm of block.matchAll(/"([^"]*?)"/g)) {
|
||
for (const cm of qm[1].matchAll(/class\s*=\s*"([^"]*)/g)) {
|
||
cm[1].split(/\s+/).filter(Boolean).forEach((t) => {
|
||
if (/^[A-Za-z][\w-]+$/.test(t)) set.add(t);
|
||
});
|
||
}
|
||
}
|
||
// 跨模板变量引用:`${sel}` 且 sel 定义含 ' is-selected' 时收录
|
||
// 形态:const sel = cond ? ' is-x' : ''; ... `${...${sel}...}`
|
||
for (const vm of block.matchAll(/(?:const|let|var)\s+(\w+)\s*=\s*[^;]*?(['"]\s*is-[A-Za-z0-9_-]*['"])/g)) {
|
||
const varName = vm[1];
|
||
if (new RegExp(`\\$\\{[^}]*\\b` + varName + `\\b[^}]*\\}`).test(block)) {
|
||
const cls = vm[2].replace(/['"\s]/g, '');
|
||
if (/^is-[A-Za-z0-9_-]+$/.test(cls)) set.add(cls);
|
||
}
|
||
}
|
||
// H5 内联脚本的 'kole-xxx is-' + type / 'is-' + status 动态拼接:
|
||
// 用 ICONS/TITLES/比较/默认值找枚举值展开(与 JSX/Vue 同口径)
|
||
{
|
||
const hasIsConcat = /['"`]\s*(?:kole-[A-Za-z0-9_-]*\s+)?is-['"`]?\s*\+\s*(?:type|status)\b/.test(block) ||
|
||
/is-\$\{(?:type|status)\}/.test(block);
|
||
if (hasIsConcat) {
|
||
const suffixes = new Set();
|
||
for (const cm of block.matchAll(/(?:ICONS|ICON|TITLES)\s*=\s*\{([^}]*)\}/g)) {
|
||
for (const qm of cm[1].matchAll(/(?:[,{\s])([A-Za-z0-9_-]+)\s*:/g)) {
|
||
if (['success', 'error', 'warning', 'info'].includes(qm[1])) suffixes.add(qm[1]);
|
||
}
|
||
}
|
||
for (const cm of block.matchAll(/type\s*(?:===|!==|==|!=)\s*['"`]([A-Za-z0-9_-]+)['"`]/g)) {
|
||
if (['success', 'error', 'warning', 'info'].includes(cm[1])) suffixes.add(cm[1]);
|
||
}
|
||
for (const sfx of suffixes) set.add('is-' + sfx);
|
||
}
|
||
}
|
||
}
|
||
return set;
|
||
}
|
||
|
||
// JSX:className="a b" / className={`a ${x}`} / className={'a': cond} 对象写法
|
||
// + 变量引用 className={classes} / className={cls}:追踪该变量定义域内的字符串字面量
|
||
function classesFromJsx(src) {
|
||
const set = new Set();
|
||
let re = /className\s*=\s*"([^"]*)"/g;
|
||
let m;
|
||
while ((m = re.exec(src))) addTokens(set, m[1]);
|
||
// className={...} 配对扫描:`[^}]*` 会在模板字符串的 ${...} 处提前截断,
|
||
// 漏掉 ' is-active' 这类写在 ${} 后面的状态类。必须按 {} 配对取完整 body。
|
||
// 注意反引号模板字符串内的 ${...}:其 { 要计 depth(它是 JS 表达式),
|
||
// 普通 '...'/"..." 内的 { 则不计数。
|
||
re = /className\s*=\s*\{/g;
|
||
while ((m = re.exec(src))) {
|
||
let depth = 1, j = m.index + m[0].length;
|
||
let inStr = null; // ', ", ` 三种字符串状态
|
||
while (j < src.length && depth > 0) {
|
||
const ch = src[j];
|
||
if (inStr === '`') {
|
||
// 模板字符串内:只有 ${ 才进入表达式(计 depth),其余字符跳过
|
||
if (ch === '\\') { j += 2; continue; }
|
||
if (ch === '`') { inStr = null; j++; continue; }
|
||
if (ch === '$' && src[j + 1] === '{') { depth++; j += 2; continue; }
|
||
if (ch === '}') {
|
||
// 模板字符串内的 } 只闭合 ${ 表达式;depth>1 才减(depth==1 是 className={ 的,模板内不能直接闭合它)
|
||
depth--;
|
||
j++;
|
||
continue;
|
||
}
|
||
j++;
|
||
continue;
|
||
}
|
||
if (inStr) {
|
||
if (ch === '\\') { j += 2; continue; }
|
||
if (ch === inStr) inStr = null;
|
||
j++;
|
||
continue;
|
||
}
|
||
if (ch === '"' || ch === "'" || ch === '`') { inStr = ch; j++; continue; }
|
||
if (ch === '{') depth++;
|
||
else if (ch === '}') depth--;
|
||
j++;
|
||
}
|
||
const body = src.slice(m.index + m[0].length, j - 1);
|
||
// body 内引号有三种形态,必须分开处理(不能用一个 /['"`]([^'"`]*?)['"`]/ 通吃,
|
||
// 否则 `...${... ? ' is-active' : ''}...` 会按 ' 配对错位,把类名切碎):
|
||
// 1) 反引号模板字符串:整体解析,${} 内三元分支的 'xxx' 逐个收
|
||
for (const tm of body.matchAll(/`([^`]*?)`/g)) {
|
||
const tpl = tm[1];
|
||
// 模板头部的静态类:`kole-tabs-tab${...}` → kole-tabs-tab
|
||
const head = tpl.split('${')[0];
|
||
head.split(/\s+/).filter(Boolean).forEach((t) => {
|
||
if (/^[A-Za-z][\w-]*$/.test(t)) set.add(t);
|
||
});
|
||
// ${} 内三元/逻辑表达式里的引号字面量:? ' is-active' : ''
|
||
for (const qm of tpl.matchAll(/'([^']*?)'/g)) {
|
||
qm[1].split(/\s+/).filter(Boolean).forEach((t) => {
|
||
if (/^[A-Za-z][\w-]*$/.test(t)) set.add(t);
|
||
});
|
||
}
|
||
// 双引号:只收 class="..." 内的,role="link" 等别的属性值不收
|
||
for (const cm of tpl.matchAll(/class\s*=\s*"([^"]*?)"/g)) {
|
||
cm[1].split(/\s+/).filter(Boolean).forEach((t) => {
|
||
if (/^[A-Za-z][\w-]*$/.test(t)) set.add(t);
|
||
});
|
||
}
|
||
}
|
||
// 2) 普通 '...' / "..." 字面量(排除已在反引号内处理过的):去 body 的反引号段后匹配
|
||
const bodyNoTpl = body.replace(/`[^`]*?`/g, ' ');
|
||
for (const qm of bodyNoTpl.matchAll(/'([^']*?)'/g)) {
|
||
qm[1].split(/\s+/).filter(Boolean).forEach((t) => {
|
||
if (/^[A-Za-z][\w-]*$/.test(t)) set.add(t);
|
||
});
|
||
}
|
||
for (const cm of bodyNoTpl.matchAll(/class\s*=\s*"([^"]*?)"/g)) {
|
||
cm[1].split(/\s+/).filter(Boolean).forEach((t) => {
|
||
if (/^[A-Za-z][\w-]*$/.test(t)) set.add(t);
|
||
});
|
||
}
|
||
// `btn-${type}` / 'btn-' + x 模板前缀:展开为对应家族
|
||
const dynBtn = body.match(/['"`]btn-['"`]?\s*[+$]/) || body.match(/btn-\$\{/);
|
||
if (dynBtn) ['btn-primary', 'btn-default', 'btn-text', 'btn-link', 'btn-danger'].forEach((t) => set.add(t));
|
||
}
|
||
// 'kole-<base>-' + <var> 全文件动态拼接(如 Tag 的 cls.push('kole-tag-' + color),
|
||
// 写在普通 JS 语句里,不在 className={} 内):找该 base 的枚举值展开
|
||
// 同理 'is-' + status 这类状态前缀:找 status/type 的枚举值展开
|
||
{
|
||
const dynAa = [...src.matchAll(/['"`](kole-[A-Za-z0-9_-]*-)['"`]?\s*\+(?!\s*['"`])/g)].map((x) => x[1]);
|
||
// 'is-' + status:is- 前可能有空格或其他类名(如 'kole-progress is-' + status),单独匹配
|
||
// `is-${type}` 插值形态(如 `kole-result is-${type}`)也要收
|
||
const dynIs = [...src.matchAll(/(?:['"`\s])(is-)\s*['"`]?\s*\+\s*(?:status|type)\b/g)].map(() => 'is-');
|
||
const dynIsTpl = [...src.matchAll(/is-\$\{(?:status|type)\}/g)].map(() => 'is-');
|
||
for (const base of [...new Set([...dynAa, ...dynIs, ...dynIsTpl])]) {
|
||
const suffixes = new Set();
|
||
const baseEsc = base.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||
for (const sm of src.matchAll(new RegExp(`['"\`]` + baseEsc + `([A-Za-z0-9_-]+)['"\`]`, 'g'))) {
|
||
suffixes.add(sm[1]);
|
||
}
|
||
// PRESET / OPTIONS / 枚举数组里的字符串:['green','red',...]
|
||
for (const am of src.matchAll(/(?:PRESET|OPTIONS|COLORS|TYPES|SIZES|ENUM)\s*=\s*\[([^\]]*)\]/g)) {
|
||
for (const qm of am[1].matchAll(/['"`]([A-Za-z0-9_-]+)['"`]/g)) suffixes.add(qm[1]);
|
||
}
|
||
// 状态比较字面量:status === 'success' 里的值是合法后缀;
|
||
// normal 从不作为 class 输出(四端一致:normal 时不拼 is-),排除。
|
||
// type 枚举:仅当值为 success/error/warning/info 状态语义时收(line/dashboard 是布局参数,不收)
|
||
if (base === 'is-') {
|
||
for (const cm of src.matchAll(/status\s*(?:===|!==|==|!=)\s*['"`]([A-Za-z0-9_-]+)['"`]/g)) {
|
||
if (cm[1] !== 'normal') suffixes.add(cm[1]);
|
||
}
|
||
// 默认值:status = 'success'(排除 status = 'normal')
|
||
for (const cm of src.matchAll(/status\s*=\s*['"`]([A-Za-z0-9_-]+)['"`]/g)) {
|
||
if (cm[1] !== 'normal') suffixes.add(cm[1]);
|
||
}
|
||
// type 默认值与比较:type = 'success' / type === 'error',仅状态语义值
|
||
for (const cm of src.matchAll(/type\s*(?:=|===|!==|==|!=)\s*['"`]([A-Za-z0-9_-]+)['"`]/g)) {
|
||
if (['success', 'error', 'warning', 'info'].includes(cm[1])) suffixes.add(cm[1]);
|
||
}
|
||
// iconMap[type] / ICON[type] 的 key:{ success: '✓', ... } 或 return { iconMap: {...} }
|
||
// key 可能无引号,块首注意空白;= 或 : 两种赋值形态都要收
|
||
for (const cm of src.matchAll(/(?:ICON|iconMap)\s*(?:=|:)\s*\{([^}]*)\}/g)) {
|
||
for (const qm of cm[1].matchAll(/['"`]([A-Za-z0-9_-]+)['"`]\s*:/g)) {
|
||
if (['success', 'error', 'warning', 'info'].includes(qm[1])) suffixes.add(qm[1]);
|
||
}
|
||
for (const qm of cm[1].matchAll(/(?:[,{\s])([A-Za-z0-9_-]+)\s*:/g)) {
|
||
if (['success', 'error', 'warning', 'info'].includes(qm[1])) suffixes.add(qm[1]);
|
||
}
|
||
}
|
||
// 同文件 <style>/CSS 已定义的 is-* 类
|
||
const stylePart2 = src.match(/<style[^>]*>([\s\S]*)<\/style>/) || [null, ''];
|
||
for (const cm of (stylePart2[1] || '').matchAll(/\.is-([A-Za-z0-9_-]+)(?![\w-])/g)) suffixes.add(cm[1]);
|
||
}
|
||
for (const sfx of suffixes) {
|
||
if (/^[A-Za-z0-9_-]+$/.test(sfx) && !sfx.includes('$')) set.add(base + sfx);
|
||
}
|
||
}
|
||
}
|
||
// HTML 字符串拼接里的 class="..."(如 '<span class="kole-plate-tag">' 写在 JS 字符串中,
|
||
// 不在 JSX 属性位):全文件扫描 class="...",与 classesFromHtml 同口径
|
||
{
|
||
const re2 = /class\s*=\s*"([^"]*)"/g;
|
||
let m2;
|
||
while ((m2 = re2.exec(src))) addTokens(set, m2[1]);
|
||
}
|
||
// const classes = ['btn', `btn-${type}`, ...] 变量定义追踪
|
||
// 注意:数组体内可能含 sizeMap[size] 这类内层 [],用配对扫描而非 [^\]]*
|
||
re = /(?:const|let|var)\s+(\w+)\s*=\s*\[/g;
|
||
while ((m = re.exec(src))) {
|
||
const varName = m[1];
|
||
let depth = 1, j = m.index + m[0].length;
|
||
while (j < src.length && depth > 0) {
|
||
if (src[j] === '[') depth++;
|
||
else if (src[j] === ']') depth--;
|
||
j++;
|
||
}
|
||
const arrBody = src.slice(m.index + m[0].length, j - 1);
|
||
if (new RegExp(`className\\s*=\\s*{\\s*${varName}\\b`).test(src)) {
|
||
const lit = arrBody.match(/['"`]([A-Za-z][\w-]*(-\$\{[^}]*\}|\+)?)['"`]/g) || [];
|
||
lit.forEach((s) => {
|
||
const inner = s.slice(1, -1);
|
||
if (inner.includes('${') || inner.endsWith('-')) {
|
||
// `btn-${type}` → 展开家族
|
||
if (inner.startsWith('btn-')) ['btn-primary', 'btn-default', 'btn-text', 'btn-link', 'btn-danger'].forEach((t) => set.add(t));
|
||
else set.add(inner);
|
||
} else set.add(inner);
|
||
});
|
||
// sizeMap[size] 引用:追踪 sizeMap 对象字面量的值(数组体可能含换行,先压平)
|
||
const arrFlat = arrBody.replace(/\s+/g, ' ');
|
||
const mapRefs = arrFlat.match(/(\w+)\s*\[\s*\w+\s*\]/g) || [];
|
||
mapRefs.forEach((ref) => {
|
||
const mapName = ref.split('[')[0].trim();
|
||
const mapDecl = src.match(new RegExp(`(?:const|let|var)\\s+${mapName}\\s*=\\s*\\{([^}]*)\\}`));
|
||
if (mapDecl) {
|
||
// { large: 'btn-lg', default: 'btn-md' } 只取冒号后的值(键是尺寸名,不是类)
|
||
const vals = mapDecl[1].match(/:\s*['"`]([A-Za-z][\w-]*)['"`]/g) || [];
|
||
vals.forEach((v) => set.add(v.replace(/^:\s*['"`]/, '').replace(/['"`]$/, '')));
|
||
}
|
||
});
|
||
}
|
||
}
|
||
re = /className\s*=\s*{`([^`]*)`}/g; // className={`kole-modal kole-modal--${size}`}
|
||
while ((m = re.exec(src))) {
|
||
const cleaned = m[1].replace(/\$\{[^}]*\}/g, ' ');
|
||
cleaned.split(/\s+/).filter(Boolean)
|
||
.filter((t) => /^[A-Za-z][\w-]*[A-Za-z0-9]$/.test(t))
|
||
.forEach((t) => set.add(t));
|
||
// kole-modal--${size} → 展开尺寸家族(从同文件 <style>/css 找 --small/--default/--large 后缀)
|
||
if (/\$\{/.test(m[1])) {
|
||
const base = (m[1].match(/([A-Za-z][\w-]*)--/) || [null, null])[1];
|
||
if (base) {
|
||
const fam = src.match(new RegExp(`${base}--([A-Za-z][\\w-]*)`, 'g')) || [];
|
||
fam.forEach((f) => set.add(f.replace(/^[.{]/, '')));
|
||
}
|
||
}
|
||
}
|
||
re = /['"]([A-Za-z][\w-]*)['"]\s*:/g; // {'is-open': open} 对象键
|
||
while ((m = re.exec(src))) {
|
||
// COLORS = { success: '#hex', normal: '#hex' } 这类颜色/枚举映射对象的 key 不是 class,跳过
|
||
// 同理 H5/Vue 内联脚本的 var color = { success: ..., normal: ... } 也要跳过
|
||
const before = src.slice(Math.max(0, m.index - 120), m.index);
|
||
if (/(?:COLORS|STYLES|MAP|ENUM|PRESET|OPTIONS|color)\s*=\s*\{[^}]*$/.test(before)) continue;
|
||
// var color = {...} 声明内的 key:向前找 var/const/let ... = {
|
||
const declStart = src.lastIndexOf('=', m.index);
|
||
if (declStart > 0) {
|
||
const declHead = src.slice(Math.max(0, declStart - 80), declStart);
|
||
if (/(?:var|const|let)\s+\w+\s*$/.test(declHead)) {
|
||
const afterOpen = src.indexOf('{', declStart);
|
||
if (afterOpen > 0 && afterOpen < m.index) {
|
||
const closeIdx = src.indexOf('}', m.index);
|
||
const semiIdx = src.indexOf(';', m.index);
|
||
// key 在 { ... } 对象内且后面是 : '#hex' 形态 → 映射表,跳过
|
||
if (closeIdx > 0 && (semiIdx < 0 || closeIdx < semiIdx) && /:\s*['"]#/.test(src.slice(m.index, m.index + 40))) continue;
|
||
}
|
||
}
|
||
}
|
||
set.add(m[1]);
|
||
}
|
||
return set;
|
||
}
|
||
|
||
// Vue:class="..." 静态 + :class="'a-' + x" 引号段 + {'k': ...} 对象键
|
||
// + computed classes 追踪(:class="classes" → computed.classes 内字符串字面量)
|
||
function classesFromVue(src) {
|
||
const set = new Set();
|
||
const tpl = (src.match(/<template>([\s\S]*)<\/template>/) || [null, src])[1];
|
||
let re = /(^|\s)class\s*=\s*"([^"]*)"/g;
|
||
let m;
|
||
while ((m = re.exec(tpl))) addTokens(set, m[2]);
|
||
re = /:class\s*=\s*"([^"]*)"/g;
|
||
const boundVars = [];
|
||
while ((m = re.exec(tpl))) {
|
||
if (/^[\w$]+$/.test(m[1].trim())) boundVars.push(m[1].trim()); // :class="classes"
|
||
const q = m[1].match(/'([A-Za-z][\w-]*)'/g) || [];
|
||
q.forEach((s) => set.add(s.slice(1, -1)));
|
||
}
|
||
// :class="tagClass" 这类绑定变量名本身不是类:进变量名噪音表
|
||
for (const v of boundVars) VAR_NOISE.add(v);
|
||
// 模板内 'is-' + status / 'is-' + type 动态拼接:用枚举展开
|
||
// status 枚举:status 比较值 + 默认值(排除 normal)
|
||
// type 枚举:仅当 type 值含 success/error/warning/info 状态语义时展开(line/dashboard 是布局参数,不拼)
|
||
{
|
||
const tplDynIs = [...tpl.matchAll(/'is-'\s*\+\s*(?:status|type|props\.(?:status|type))\b/g)];
|
||
if (tplDynIs.length) {
|
||
const suffixes = new Set();
|
||
for (const cm of tpl.matchAll(/status\s*(?:===|!==|==|!=)\s*['"`]([A-Za-z0-9_-]+)['"`]/g)) {
|
||
if (cm[1] !== 'normal') suffixes.add(cm[1]);
|
||
}
|
||
for (const cm of src.matchAll(/status\s*=\s*['"`]([A-Za-z0-9_-]+)['"`]/g)) {
|
||
if (cm[1] !== 'normal') suffixes.add(cm[1]);
|
||
}
|
||
// type 枚举:只收状态语义值
|
||
const typeVals = new Set();
|
||
for (const cm of tpl.matchAll(/type\s*(?:===|!==|==|!=)\s*['"`]([A-Za-z0-9_-]+)['"`]/g)) typeVals.add(cm[1]);
|
||
for (const cm of src.matchAll(/type\s*:\s*\{\s*type\s*:\s*String\s*,[^}]*?default\s*:\s*['"`]([A-Za-z0-9_-]+)['"`]/g)) typeVals.add(cm[1]);
|
||
for (const cm of src.matchAll(/type\s*=\s*['"`]([A-Za-z0-9_-]+)['"`]/g)) typeVals.add(cm[1]);
|
||
// iconMap key 也是合法 type 枚举:{ success: '✓', ... } 或 return { iconMap: {...} }
|
||
for (const cm of src.matchAll(/(?:ICON|iconMap)\s*(?:=|:)\s*\{([^}]*)\}/g)) {
|
||
for (const qm of cm[1].matchAll(/(?:[,{\s])([A-Za-z0-9_-]+)\s*:/g)) typeVals.add(qm[1]);
|
||
}
|
||
for (const v of typeVals) {
|
||
if (['success', 'error', 'warning', 'info'].includes(v)) suffixes.add(v);
|
||
}
|
||
// 同文件 style 已定义的 is-* 类
|
||
const stylePartT = src.match(/<style[^>]*>([\s\S]*)<\/style>/) || [null, ''];
|
||
for (const cm of (stylePartT[1] || '').matchAll(/\.is-([A-Za-z0-9_-]+)(?![\w-])/g)) suffixes.add(cm[1]);
|
||
for (const sfx of suffixes) {
|
||
if (/^[A-Za-z0-9_-]+$/.test(sfx) && !sfx.includes('$')) set.add('is-' + sfx);
|
||
}
|
||
}
|
||
}
|
||
// 对象键:{'is-open': open} 带引号 + { fixed } 简写(无引号,key 即类名)
|
||
re = /['"]([A-Za-z][\w-]*)['"]\s*:/g;
|
||
while ((m = re.exec(tpl))) set.add(m[1]);
|
||
re = /:class\s*=\s*"\{\s*([A-Za-z][\w-]*)\s*\}"/g;
|
||
while ((m = re.exec(tpl))) set.add(m[1]);
|
||
// 追踪 computed/变量定义里的类名字面量(whole-file sweep:
|
||
// options-API/composition 都覆盖;只收「像类名」的 token 防噪音)
|
||
const script = (src.match(/<script[^>]*>([\s\S]*)<\/script>/) || [null, ''])[1];
|
||
const litAll = script.match(/['"`]([A-Za-z][\w-]*)['"`]/g) || [];
|
||
litAll.forEach((s) => {
|
||
const inner = s.slice(1, -1);
|
||
// 结尾带 - 的是 'kole-tag-' + x 这类拼接前缀残留:不是完整类,跳过(由动态拼接展开处理)
|
||
if (inner.endsWith('-')) return;
|
||
if (inner.includes('-') || ['btn', 'spinner', 'active', 'open', 'selected', 'disabled', 'loading'].includes(inner)) set.add(inner);
|
||
});
|
||
// Vue script 里的 'kole-<base>-' + var 动态拼接:同 JSX,用枚举展开
|
||
// 同理 'is-' + status:is- 前可能有空格/类名前缀
|
||
{
|
||
const dynAa = [...script.matchAll(/['"`](kole-[A-Za-z0-9_-]*-)['"`]?\s*\+(?!\s*['"`])/g)].map((x) => x[1]);
|
||
const dynIsV = [...script.matchAll(/(?:['"`\s])(is-)\s*['"`]?\s*\+\s*(?:status|type|props\.(?:status|type))\b/g)].map(() => 'is-');
|
||
for (const base of [...new Set([...dynAa, ...dynIsV])]) {
|
||
const suffixes = new Set();
|
||
const baseEsc = base.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||
for (const sm of script.matchAll(new RegExp(`['"\`]` + baseEsc + `([A-Za-z0-9_-]+)['"\`]`, 'g'))) {
|
||
suffixes.add(sm[1]);
|
||
}
|
||
for (const am of script.matchAll(/(?:PRESET|OPTIONS|COLORS|TYPES|SIZES|ENUM)\s*=\s*\[([^\]]*)\]/g)) {
|
||
for (const qm of am[1].matchAll(/['"`]([A-Za-z0-9_-]+)['"`]/g)) suffixes.add(qm[1]);
|
||
}
|
||
// iconMap / ICON 对象的 key(含无引号形态):{ success: '✓', ... } 或 return { iconMap: {...} }
|
||
// 注意块首第一个 key 前面是 '{ ',用 [,{\s] 分隔符统收(含块首空白);= 或 : 两种形态都要收
|
||
for (const cm of script.matchAll(/(?:ICON|iconMap)\s*(?:=|:)\s*\{([^}]*)\}/g)) {
|
||
for (const qm of cm[1].matchAll(/['"`]([A-Za-z0-9_-]+)['"`]\s*:/g)) {
|
||
if (['success', 'error', 'warning', 'info'].includes(qm[1])) suffixes.add(qm[1]);
|
||
}
|
||
for (const qm of cm[1].matchAll(/(?:[,{\s])([A-Za-z0-9_-]+)\s*:/g)) {
|
||
if (['success', 'error', 'warning', 'info'].includes(qm[1])) suffixes.add(qm[1]);
|
||
}
|
||
}
|
||
// 同文件 <style> 里该 base 的已定义类:.kole-tag-green {...}
|
||
const stylePart = src.match(/<style[^>]*>([\s\S]*)<\/style>/) || [null, ''];
|
||
for (const cm of (stylePart[1] || '').matchAll(new RegExp(`\\.` + baseEsc + `([A-Za-z0-9_-]+)(?![\\w-])`, 'g'))) {
|
||
suffixes.add(cm[1]);
|
||
}
|
||
// is- 前缀:status 比较值(排除 normal,它从不输出为类);
|
||
// type 比较值/默认值/iconMap key 仅状态语义值(success/error/warning/info)收,line/dashboard 不收
|
||
if (base === 'is-') {
|
||
for (const cm of script.matchAll(/status\s*(?:===|!==|==|!=)\s*['"`]([A-Za-z0-9_-]+)['"`]/g)) {
|
||
if (cm[1] !== 'normal') suffixes.add(cm[1]);
|
||
}
|
||
for (const cm of script.matchAll(/props\.status\s*(?:===|!==|==|!=)\s*['"`]([A-Za-z0-9_-]+)['"`]/g)) {
|
||
if (cm[1] !== 'normal') suffixes.add(cm[1]);
|
||
}
|
||
for (const cm of script.matchAll(/type\s*(?:===|!==|==|!=)\s*['"`]([A-Za-z0-9_-]+)['"`]/g)) {
|
||
if (['success', 'error', 'warning', 'info'].includes(cm[1])) suffixes.add(cm[1]);
|
||
}
|
||
for (const cm of script.matchAll(/props\.type\s*(?:===|!==|==|!=)\s*['"`]([A-Za-z0-9_-]+)['"`]/g)) {
|
||
if (['success', 'error', 'warning', 'info'].includes(cm[1])) suffixes.add(cm[1]);
|
||
}
|
||
// type 默认值:type: { type: String, default: 'success' }
|
||
for (const cm of script.matchAll(/type\s*:\s*\{\s*type\s*:\s*String\s*,[^}]*?default\s*:\s*['"`]([A-Za-z0-9_-]+)['"`]/g)) {
|
||
if (['success', 'error', 'warning', 'info'].includes(cm[1])) suffixes.add(cm[1]);
|
||
}
|
||
}
|
||
for (const sfx of suffixes) {
|
||
if (/^[A-Za-z0-9_-]+$/.test(sfx) && !sfx.includes('$')) set.add(base + sfx);
|
||
}
|
||
}
|
||
}
|
||
// `btn-${x}` 模板前缀 → 展开家族
|
||
if (/btn-\$\{|['"`]btn-['"`]?\s*[+`]/.test(script)) ['btn-primary', 'btn-default', 'btn-text', 'btn-link', 'btn-danger'].forEach((t) => set.add(t));
|
||
// sizeMap 等映射对象的值
|
||
const maps = script.match(/(?:const|let|var)\s+\w+\s*=\s*\{([^}]*)\}/g) || [];
|
||
maps.forEach((decl) => {
|
||
const vals = decl.match(/:\s*['"`]([A-Za-z][\w-]*)['"`]/g) || [];
|
||
vals.forEach((v) => set.add(v.replace(/^[::\s]*['"`]/, '').replace(/['"`]$/, '').replace(/^:\s*/, '').trim()));
|
||
});
|
||
return set;
|
||
}
|
||
|
||
// CSS:.foo 选择器定义集(排除 url()/数值小数)
|
||
function classesFromCss(src) {
|
||
const set = new Set();
|
||
const noUrls = src.replace(/url\([^)]*\)/g, 'url()');
|
||
const re = /\.(-?[_a-zA-Z][\w-]*)/g;
|
||
let m;
|
||
while ((m = re.exec(noUrls))) set.add(m[1]);
|
||
return set;
|
||
}
|
||
|
||
// 结构骨架:小写标签集合 + 元素个数
|
||
function structureOf(src, onlyTemplate) {
|
||
let s = src;
|
||
if (onlyTemplate) s = (src.match(/<template>([\s\S]*)<\/template>/) || [null, src])[1];
|
||
const tags = new Set();
|
||
let count = 0;
|
||
const re = /<([a-zA-Z][\w-]*)\b/g;
|
||
let m;
|
||
while ((m = re.exec(s))) {
|
||
if (/^[a-z]/.test(m[1]) && !['br', 'img', 'input', 'meta', 'link'].includes(m[1]) || ['div', 'span', 'button', 'table', 'ul', 'li', 'form', 'input', 'select', 'img', 'a', 'p', 'h1', 'h2', 'h3', 'i', 'em', 'svg', 'path', 'br', 'hr', 'label', 'textarea', 'template', 'slot', 'block', 'view', 'text', 'scroll-view', 'uni-view'].includes(m[1])) {
|
||
tags.add(m[1]);
|
||
count++;
|
||
} else if (/^[a-z]/.test(m[1])) {
|
||
tags.add(m[1]);
|
||
count++;
|
||
}
|
||
}
|
||
return { tags: [...tags].sort(), elements: count };
|
||
}
|
||
|
||
/* ---------- 噪音过滤 ---------- */
|
||
// H5 演示页的布局包装类(非组件契约类):三框架端天生没有,计入 diff 是噪音
|
||
const DEMO_WRAPPERS = new Set(['sub', 'group', 'row', 'demo', 'demo-block', 'hint', 'field', 'wrap', 'wrapper',
|
||
'container', 'example', 'sample', 'preview', 'section', 'page', 'toolbar',
|
||
// P0 去噪:kole- 演示页壳(实测 77 差异中 H5 独有 355 项的主力噪音)
|
||
'kole-page', 'kole-h2', 'kole-desc', 'kole-demo', 'kole-demo__title', 'kole-code-tip', 'kole-panel',
|
||
'kole-demo-toolbar', 'kole-demo-panel', 'kole-box', 'kole-card', 'kole-btn', 'kole-h1', 'kole-sub',
|
||
'kole-row', 'kole-group', 'kole-section', 'kole-toolbar', 'kole-note', 'kole-tip', 'kole-ph']);
|
||
// 变量名/属性名误收(:class="classes" 的变量名本身不是类)
|
||
const VAR_NOISE = new Set(['classes', 'cls', 'className', 'class',
|
||
// P1 补:Vue/React 模板与脚本中的 prop/变量/事件名误收(实测 input 等组件残留)
|
||
'modelValue', 'resolvedType', 'text', 'input', 'value', 'placeholder', 'button',
|
||
'default', 'size', 'type', 'color', 'checked', 'selected', 'disabled', 'loading',
|
||
'open', 'visible', 'active', 'current', 'index', 'key', 'label', 'title', 'name',
|
||
'onChange', 'onClick', 'onClose', 'onSelect', 'handleClick', 'handleInput',
|
||
'valid', 'invalid', 'ok', 'error', 'cell', 'log', 'col', 'light', 'dark', 'auto',
|
||
'sm', 'lg', 'cur', 'lab', 'desc', 'unknown', 'radio', 'none', 'round', 'true',
|
||
'horizontal', 'vertical', 'primary', 'isOpen', 'allSelected', 'tabAdd', 'step',
|
||
'min', 'max', 'sans-serif', 'number', 'numeric', 'activeId', 'sideIdx', 'c.key',
|
||
'd.status', 'isOpen', 'animClass', 'circleCirc', 'arcLen', 'rowClick', 'sortChange',
|
||
'selectionChange', 'tabRemove', 'stepClick', 'rowCb', 'linkBtn',
|
||
// P1 补:H5 演示私有类与 Vue 事件名(非跨端语义)
|
||
'row-cb', 'link-btn', 'sort-ind', 'selection-change', 'sort-change', 'row-click',
|
||
'tab-remove', 'tab-add', 'step-click', 'selection-change', 'cell', 'unknown',
|
||
// H5 演示变体名(与框架端 prop 枚举语义一致,非缺失)
|
||
'danger', 'sm', 'lg', 'index', 'cur', 'lab', 'log', 'col', 'active',
|
||
'type-line', 'type-card', 'type-pill', 'placement-bottom', 'placement-top',
|
||
'slider-row', 'kole-thumb', 'kole-sheet-btn', 'kole-demo-banner', 'kole-demo-spacer',
|
||
'kole-countdown-done', 'kole-hint', 'light', 'dark', 'auto', 'default', 'size',
|
||
'text', 'ok', 'error', 'valid', 'invalid', 'primary', 'isOpen', 'allSelected',
|
||
// P1-2 补:H5 演示交互私有类(transfer 左右列标识、sidemenu 演示开关、treetable 键盘提示)
|
||
'left', 'right', 'toggle', 'menuitem', 'checkbox', 'treeitem', 'tree', 'Enter',
|
||
'collapsed',
|
||
// P1-2 补:Vue 尺寸枚举(is-sm/small/is-lg/large 与 H5/React 具体尺寸类语义等价)
|
||
'is-sm', 'small', 'is-lg', 'large',
|
||
// P1-3 补:从不作为 class 输出的状态默认值(四端一致:只出现在 prop 默认值/比较/COLORS key)
|
||
'normal',
|
||
// P1-4 补:H5 演示私有状态与主题演示壳(非跨端语义)
|
||
'is-muted', 'up', 'down', '!hasChild', 'kole-mode', 'kole-dark',
|
||
'kole-card__body', 'kole-btn--primary',
|
||
// role= 属性值残留碎片
|
||
'role=']);
|
||
// JS 表达式碎片(从 className={...} 对象键/三元表达式正则中误收的符号与标识符碎片)
|
||
const EXPR_NOISE = new Set(['?', ':', "'", '"', '+', '-', '*', '/', '%', '!', '&', '|', '=',
|
||
'===', '==', '!==', '=>', 'is-', "is-'", 'it.status', '(i', 'number', 'numeric',
|
||
'1200', 'is-active\'', 'is-open\'', 'is-checked\'', 'is-selected\'', "''", "'')"]);
|
||
function deNoise(set) {
|
||
for (const x of [...set]) {
|
||
if (DEMO_WRAPPERS.has(x) || VAR_NOISE.has(x) || EXPR_NOISE.has(x)) { set.delete(x); continue; }
|
||
if (x.includes('$') || x.includes('{') || x.includes('}')) set.delete(x); // 模板残留
|
||
// 单字符与纯符号碎片:不可能是合法 class
|
||
if (/^[^a-zA-Z_-]*$/.test(x) || x.length <= 1) { set.delete(x); continue; }
|
||
// 含引号/括号/分号的表达式残留
|
||
if (/['"();,]/.test(x)) { set.delete(x); continue; }
|
||
// 动态前缀残留(如 type-${type} 展开失败剩下的 'type-'):结尾带 - 的不是完整类
|
||
if (x.endsWith('-')) { set.delete(x); continue; }
|
||
}
|
||
return set;
|
||
}
|
||
/* ---------- 主流程 ---------- */
|
||
|
||
const ENDS = ['h5', 'react', 'vue2', 'vue3'];
|
||
const items = [];
|
||
let readErrors = [];
|
||
|
||
for (const c of components) {
|
||
const files = c.files || {};
|
||
const kind = { h5: files.html, react: files.jsx, vue2: files.vue2, vue3: files.vue3 };
|
||
const cssFile = files.css;
|
||
const sets = {};
|
||
const missing = [];
|
||
for (const end of ENDS) {
|
||
if (!kind[end]) {
|
||
missing.push(end);
|
||
sets[end] = new Set();
|
||
continue;
|
||
}
|
||
try {
|
||
const src = readFileSync(join(ROOT, 'frameworks', basename(kind[end])), 'utf8');
|
||
sets[end] = end === 'h5' ? classesFromHtml(src)
|
||
: end === 'react' ? classesFromJsx(src) : classesFromVue(src);
|
||
} catch (e) {
|
||
readErrors.push(`${c.slug}/${end}: ${e.message}`);
|
||
sets[end] = new Set();
|
||
}
|
||
}
|
||
let cssSet = new Set();
|
||
try {
|
||
if (cssFile) cssSet = classesFromCss(readFileSync(join(ROOT, 'frameworks', basename(cssFile)), 'utf8'));
|
||
} catch (e) {
|
||
readErrors.push(`${c.slug}/css: ${e.message}`);
|
||
}
|
||
|
||
// 结构骨架
|
||
const structs = {};
|
||
for (const end of ENDS) {
|
||
if (!kind[end]) {
|
||
structs[end] = { tags: [], elements: 0 };
|
||
continue;
|
||
}
|
||
try {
|
||
const src = readFileSync(join(ROOT, 'frameworks', basename(kind[end])), 'utf8');
|
||
structs[end] = structureOf(src, end === 'vue2' || end === 'vue3');
|
||
} catch {
|
||
structs[end] = { tags: [], elements: 0 };
|
||
}
|
||
}
|
||
|
||
// diff:union 视角 + 共识视角(先去掉 H5 演示包装类与变量名噪音)
|
||
for (const end of ENDS) deNoise(sets[end]);
|
||
const union = new Set();
|
||
const presence = new Map(); // class -> [ends...]
|
||
for (const end of ENDS) {
|
||
for (const cls of sets[end]) {
|
||
union.add(cls);
|
||
if (!presence.has(cls)) presence.set(cls, []);
|
||
presence.get(cls).push(end);
|
||
}
|
||
}
|
||
const diffs = [];
|
||
for (const [cls, has] of presence) {
|
||
if (has.length === ENDS.length) continue;
|
||
const absent = ENDS.filter((e) => !has.includes(e));
|
||
const type = has.length >= 3 ? 'missing-consensus' // ≥3 端有、某端缺:疑似真实遗漏
|
||
: has.length === 1 ? 'extra-single-end' // 仅一端有:多为演示包装/条件类
|
||
: 'symmetric'; // 各两端:需人工看一眼
|
||
diffs.push({ class: cls, presentIn: has, missingIn: absent, type });
|
||
}
|
||
// css 定义了但四端都没用到的类(死样式嫌疑,只列不判)
|
||
const cssUnused = [...cssSet].filter((x) => !union.has(x));
|
||
|
||
let severity = 'none';
|
||
if (diffs.some((d) => d.type === 'missing-consensus')) severity = 'high';
|
||
else if (diffs.some((d) => d.type === 'symmetric')) severity = 'medium';
|
||
else if (diffs.length) severity = 'low';
|
||
|
||
items.push({
|
||
slug: c.slug,
|
||
files: Object.fromEntries(ENDS.map((e) => [e, kind[e] ? basename(kind[e]) : null])),
|
||
missingFiles: missing,
|
||
platforms: Object.fromEntries(ENDS.map((e) => [e, [...sets[e]].sort()])),
|
||
css: [...cssSet].sort(),
|
||
structure: structs,
|
||
diffs,
|
||
cssUnused,
|
||
severity
|
||
});
|
||
}
|
||
|
||
const identical = items.filter((i) => i.diffs.length === 0).length;
|
||
const report = {
|
||
generated: new Date().toISOString().replace('T', ' ').slice(0, 19),
|
||
version: (data.meta && data.meta.version) || null,
|
||
total: items.length,
|
||
identical,
|
||
differing: items.length - identical,
|
||
severityCounts: {
|
||
high: items.filter((i) => i.severity === 'high').length,
|
||
medium: items.filter((i) => i.severity === 'medium').length,
|
||
low: items.filter((i) => i.severity === 'low').length,
|
||
none: identical
|
||
},
|
||
readErrors,
|
||
samples: items.filter((i) => i.diffs.length).slice(0, 3).map((i) => ({
|
||
slug: i.slug,
|
||
severity: i.severity,
|
||
diffs: i.diffs.slice(0, 8)
|
||
})),
|
||
items
|
||
};
|
||
|
||
mkdirSync(dirname(OUT), { recursive: true });
|
||
writeFileSync(OUT, JSON.stringify(report, null, 2) + '\n');
|
||
|
||
console.log(`检查组件: ${report.total}`);
|
||
console.log(`完全一致: ${report.identical}`);
|
||
console.log(`有差异 : ${report.differing} (high ${report.severityCounts.high} / medium ${report.severityCounts.medium} / low ${report.severityCounts.low})`);
|
||
if (readErrors.length) {
|
||
console.log('读取失败:');
|
||
readErrors.slice(0, 10).forEach((e) => console.log(' ' + e));
|
||
}
|
||
console.log(`written: ${OUT}`);
|
||
console.log('说明: high=≥3端共识缺失(疑似真实遗漏,需建任务修); medium=两端对称差异(人工看); low=单端独有(多为演示包装类)。发现差异是本任务的价值,不在本次顺手修。');
|
||
|
||
if (report.total !== components.length) {
|
||
console.error(`FAIL: total=${report.total}; expected ${components.length}`);
|
||
process.exit(1);
|
||
}
|
||
if (readErrors.length) {
|
||
console.error(`FAIL: ${readErrors.length} file read error(s)`);
|
||
process.exit(1);
|
||
}
|
||
console.log('cross-platform differences are informational; historical differences do not fail this check');
|