Files
aurora-admin/tools/verify-cross-platform.mjs
T
aurora-admin eb25feedaf feat(S6-P21): 组件族参数化(13族/44成员/导航族15文件同源/79→48概念组件)
【本次核心 · S6-P21】
- 族层数据:families.json + 44 份契约注入 family/familyRole/familyParams;
  data.json / data.js / site/details 同步。13 族 / 44 成员 / 35 独立 → 概念组件 79→48。
- 79 个 slug 全保留、集合逐一不变(铁律 5 对外承诺未破);frameworks 仍 395 文件、薄壳仍 79。
- 归族判据为契约中可核对字段(semanticTypeCandidates 重叠 / anatomy 为同一骨架子集 /
  变体维度同构 / doNotInvent 显式从属声明),每族 mergeBasis 写明依据,不按名字猜。
- 实现层合并(导航族端到端切片):tools/gen-family-impl.mjs 从 5 端模板生成
  TopMenu / SideMenu / MixedNavigation 共 15 文件,参数 direction=top|side|mixed;
  三份 CSS md5 完全相同 = 一份样式表服务三个组件。
- 新增 tools/gen-families.mjs、tools/gen-family-impl.mjs、tools/verify-families.mjs、
  tools/lib/family-model.mjs、tools/lib/family-impl/nav-menu/*.tpl。

【同时清掉此前已完成但未提交的批次】
生成物(data.json / data.js / site/sources / site/components 薄壳 / sitemap.xml / tests 报告)
跨阶段交织,无法拆成互相自洽的多个提交,故按既有批量风格合并提交:
- Package:三端可 import(S5-P18)+ 发布到私有 npm 源
- Docs site:导航语言改下拉(S5-P19)、详情页代码块默认展开、中英切换完整性
- Security:生产部署链审计修复(2026-09-19)+ 线上部署
- Theme modes 日间/夜间/自动;S1-P4 data.js 瘦身;S2-P5 暗色;S2-P6 跨端一致性;
  S2-P7 行为断言;S2-P9 FAQ;S3-P8 RTL;S3-P9 契约缺口解释层;S4-P12 发布流程
- 补入 tools/pack-deploy.mjs、run-site-smoke.mjs、verify-*.mjs,.dockerignore、
  安全审计修复与待决策项.md

【验收】
- node tools/verify-families.mjs → OK: 族层端到端一致(13 族 / 44 成员 / 79 组件不变 / 395 文件不变)
- node tools/verify-cross-platform.mjs → 79/79 identical(HEAD 基线 high 44)
- node tools/run-regression.mjs → 100%(79/79 页,1017/1017 断言,N/A 34),连跑 8 次一致,0 超时
- 逐页实测:topmenu / sidemenu / mixednavigation 各 13/13,帧内 direction 参数正确,0 JS 错误
- 零运行时依赖 OK;build-site.ps1 ASCII-only OK

【未纳入】site/components/<slug>/ 平台薄壳 316 个 —— 历史从未跟踪且属构建产物,保持现状。
2026-09-20 03:32:31 +08:00

704 lines
34 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/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]);
// H5 演示页 DOM 由内联 <script> 动态渲染(如 Tree 的 `aa-tree-node${sel}`):
// 扫描 script 内模板字符串/引号字面量中的类名,与 JSX 同口径
const scripts = src.match(/<script[^>]*>([\s\S]*?)<\/script>/g) || [];
for (const block of scripts) {
// 反引号模板:`aa-tabs-tab${...}` → 头部静态类 + ${} 内三元分支引号类
// 头部有两种形态:裸类(`aa-tabs-tab${`)与 HTML 属性(`<div class="aa-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="aa-xxx' + x / "aa-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);
});
}
// 裸 aa-/is- 类(如 'aa-mention-item' + (...) 中的独立 token)
const inner = qm[1].trim();
if (/^(?:aa-|is-|has-|btn-|col-)[A-Za-z0-9_-]+$/.test(inner)) set.add(inner);
}
// className = 'aa-message is-' 这类赋值语句:引号内以空格结尾的类前缀也要收头部类
for (const am of block.matchAll(/(?:className|\.className)\s*=\s*'([^']*?)'/g)) {
am[1].split(/\s+/).filter(Boolean).forEach((t) => {
if (/^(?:aa-|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 内联脚本的 'aa-xxx is-' + type / 'is-' + status 动态拼接:
// 用 ICONS/TITLES/比较/默认值找枚举值展开(与 JSX/Vue 同口径)
{
const hasIsConcat = /['"`]\s*(?:aa-[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];
// 模板头部的静态类:`aa-tabs-tab${...}` → aa-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));
}
// 'aa-<base>-' + <var> 全文件动态拼接(如 Tag 的 cls.push('aa-tag-' + color),
// 写在普通 JS 语句里,不在 className={} 内):找该 base 的枚举值展开
// 同理 'is-' + status 这类状态前缀:找 status/type 的枚举值展开
{
const dynAa = [...src.matchAll(/['"`](aa-[A-Za-z0-9_-]*-)['"`]?\s*\+(?!\s*['"`])/g)].map((x) => x[1]);
// 'is-' + status:is- 前可能有空格或其他类名(如 'aa-progress is-' + status),单独匹配
// `is-${type}` 插值形态(如 `aa-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="aa-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={`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} → 展开尺寸家族(从同文件 <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);
// 结尾带 - 的是 'aa-tag-' + x 这类拼接前缀残留:不是完整类,跳过(由动态拼接展开处理)
if (inner.endsWith('-')) return;
if (inner.includes('-') || ['btn', 'spinner', 'active', 'open', 'selected', 'disabled', 'loading'].includes(inner)) set.add(inner);
});
// Vue script 里的 'aa-<base>-' + var 动态拼接:同 JSX,用枚举展开
// 同理 'is-' + status:is- 前可能有空格/类名前缀
{
const dynAa = [...script.matchAll(/['"`](aa-[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 的已定义类:.aa-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 去噪:aa- 演示页壳(实测 77 差异中 H5 独有 355 项的主力噪音)
'aa-page', 'aa-h2', 'aa-desc', 'aa-demo', 'aa-demo__title', 'aa-code-tip', 'aa-panel',
'aa-demo-toolbar', 'aa-demo-panel', 'aa-box', 'aa-card', 'aa-btn', 'aa-h1', 'aa-sub',
'aa-row', 'aa-group', 'aa-section', 'aa-toolbar', 'aa-note', 'aa-tip', 'aa-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', 'aa-thumb', 'aa-sheet-btn', 'aa-demo-banner', 'aa-demo-spacer',
'aa-countdown-done', 'aa-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', 'aa-mode', 'aa-dark',
'aa-card__body', 'aa-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 !== 79) {
console.error(`FAIL: total=${report.total}; expected 79`);
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');