#!/usr/bin/env node /** * verify-cross-platform.mjs — S2-P6 跨端一致性自动验证(方案 B:静态结构比对) * * 对 79 个组件的 4 端实现(H5 / React-JSX / Vue2 / Vue3)提取 class 集合与 * 结构骨架(标签集合 + 元素数),做集合 diff,输出 tests/cross-platform-report.json。 * * - 零运行时依赖(只读文件 + 正则,不渲染、不联网)。 * - 本脚本只负责「暴露差异」,发现差异 = 任务价值,不在本脚本内顺手修。 * - 退出码:0 = 报告成功生成;1 = 脚本自身失败(读不到数据、无输出、total < 79)。 * * 用法: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]); 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="a" 后跟中括号数组写法:className={[...]} 内的引号段 re = /className\s*=\s*{([^}]*)}/g; while ((m = re.exec(src))) { const body = m[1]; // 模板字符串/引号字面量里的类名候选(btn- 前缀、aa- 前缀、已知小词) const lit = body.match(/['"`]([A-Za-z][\w-]*)['"`]/g) || []; lit.forEach((s) => set.add(s.slice(1, -1))); // `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)); } // 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={`aa-modal aa-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)); // aa-modal--${size} → 展开尺寸家族(从同文件