方案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组件未动(只读消费)。
330 lines
13 KiB
JavaScript
330 lines
13 KiB
JavaScript
#!/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);
|
||
}
|