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组件未动(只读消费)。
This commit is contained in:
@@ -0,0 +1,329 @@
|
||||
#!/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);
|
||||
}
|
||||
Reference in New Issue
Block a user