Files
aurora-admin/tools/verify-cross-platform.mjs
T
aurora-admin 32292c01f8 feat(S2-P6): 跨端一致性静态验证(79/79/high44/F1-F4已追加)
方案B静态结构比对(零依赖零联网):79组件x4端 class集合+结构骨架
双diff;演示包装类/变量名/模板残留降噪;high/medium/low分级。
实测:完全一致2,有差异77(high44/medium22/low11);high主因已追加
S2-P6-F1~F4任务包,不在本任务内修。回归零影响:report.json 79/79、
data.json 79组件未动(只读消费)。
2026-09-14 03:24:18 +08:00

330 lines
13 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]);
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} → 展开尺寸家族(从同文件 <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))) 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)));
}
re = /['"]([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);
if (inner.includes('-') || ['btn', 'spinner', 'active', 'open', 'selected', 'disabled', 'loading'].includes(inner)) set.add(inner);
});
// `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']);
// 变量名/属性名误收(:class="classes" 的变量名本身不是类)
const VAR_NOISE = new Set(['classes', 'cls', 'className', 'class']);
function deNoise(set) {
for (const x of [...set]) {
if (DEMO_WRAPPERS.has(x) || VAR_NOISE.has(x)) { set.delete(x); continue; }
if (x.includes('$') || x.includes('{') || x.includes('}')) set.delete(x); // 模板残留
}
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.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} < 79`);
process.exit(1);
}