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:
aurora-admin
2026-09-14 03:24:18 +08:00
parent e13049f5b9
commit 32292c01f8
4 changed files with 16862 additions and 1 deletions
+5
View File
@@ -4,6 +4,11 @@
## [Unreleased]
### S2-P6 · 跨端一致性自动验证(方案 B:静态结构比对)
- 新增 `tools/verify-cross-platform.mjs`(零依赖零联网):79 组件 × 4 端(H5/React/Vue2/Vue3)class 集合 + 结构骨架双 diff,演示包装类/变量名/模板残留降噪,high/medium/low 分级;产出 `tests/cross-platform-report.json`(79 条全量 + samples 3)。
- 实测:完全一致 2,有差异 77(high 44/medium 22/low 11)。high 主因:React 端 `is-disabled/is-active` 缺失、H5 演示页缺框架端结构类、Tag/Select/Input 变体类缺失——已追加为 ROADMAP 的 S2-P6-F1~F4,不在本任务内修。
- 回归零影响:`tests/report.json` 79/79、`site/data.json` 79 组件均未动(脚本只读消费)。
### S2-P5 · 暗色模式真正实现(选型 A:演示页随站点同步反色)
- 令牌文件新增 `html.aa-dark` 组(31 个 `--au-`,≥15 达标):背景三层递进、文字四层、品牌/语义色向白提亮;10 项文字对比度全部 ≥4.5:1(脚本实测)。
- `site/style.css` 暗色块精简为结构样式(令牌值收归令牌文件,删旧内嵌值与“演示页保持浅色”规则);`site/app.js` 删死代码 `DARK_TOKENS`,`injectIframeTheme` 同步 `aa-dark` 类到演示 iframe。
+46 -1
View File
@@ -685,6 +685,51 @@ console.log('差异样本:', JSON.stringify(r.samples.slice(0,3),null,1));
**重要说明**:**这个任务的产出可能揭露一批真实的不一致**。这不是失败 —— 是这项任务的价值。发现的差异应作为新任务包列出,不在本任务内顺手修(避免范围蔓延)。
**✅ 完成(方案 B:静态结构比对,零依赖零联网)** — 验收输出:
```
检查组件: 79
完全一致: 2
有差异 : 77 (high 44 / medium 22 / low 11)
written: tests/cross-platform-report.json
```
改动文件:`tools/verify-cross-platform.mjs`(新建,class 集合 + 结构骨架双 diff,演示包装类/变量名/模板残留降噪,high/medium/low 分级)→ `tests/cross-platform-report.json`(产出,79 条全量 + samples 3)。
回归:`tests/report.json` 79/79 未动,`site/data.json` 79 组件未动(只读消费)。
发现的新问题 → 已追加为 S2-P6-F1~F4(见下方),不在本任务内修。
### S2-P6-F1 · React 端缺失 `is-disabled` 等状态类(5 组件) | 预算 ~2 h | 优先级 P1
**目标**:`is-disabled`(5x)、`is-active`(3x)在 React 端补齐,与三端共识对齐。
**依据**:`tests/cross-platform-report.json` 的 `missing-consensus` 聚合。React 端多用 `disabled` 属性而非状态类,需确认是「有意用属性替代」还是「遗漏」——若有意,需在契约注明;若遗漏,补类。
**验收**:重跑 `node tools/verify-cross-platform.mjs`,`is-disabled/is-active miss:react` 条数归零或有契约说明。
### S2-P6-F2 · H5 演示页缺失框架端结构类 | 预算 ~2 h | 优先级 P2
**目标**:`col-selection/right/col-fixed/action`(table 系)、`ico/horizontal` 等「三框架端有、H5 演示页缺」的类,核对 H5 演示是否漏了对应变体展示。
**依据**:同上报告。H5 是静态演示页,可能只是没展示该变体(非 bug);但若是变体缺失展示,补演示片段即可。
**验收**:逐项标注「演示缺展示(补)」或「框架端多余(不动)」,`miss:h5` 的 high 条数下降或全部有结论。
### S2-P6-F3 · React 端 Tag/Select/Input 变体类缺失 | 预算 ~2 h | 优先级 P1
**目标**:`aa-tag-dot/aa-tag-custom`、`au-input-lg/sm`、`aa-select-empty` 等 React 端缺失的变体类,核对是未实现该变体还是类名拼写差异。
**依据**:同上报告(input 的 `au-input-lg/sm` 在本轮提取器修复后已证明是「追踪不到」而非「真缺失」的前车之鉴——先复核再修)。
**验收**:逐项复核,有真缺失则补实现;误报则修提取器。
### S2-P6-F4 · P6 报告纳入 CI 门禁 | 预算 ~30 min | 优先级 P2
**目标**:`regression.yml` 追加一步 `node tools/verify-cross-platform.mjs`,`total < 79` 或脚本非零退出时标红(high 差异只告警不阻塞,避免把历史差异变成合并 blocker)。
**依赖**:S1-P3(CI 已存在)。
**验收**:workflow 文件含该步骤且语法正确。
---
### S2-P7 · 行为断言 | 预算 ~1.5 天 | 优先级 P1
@@ -990,7 +1035,7 @@ figma export OK
| P3 | CI 回归 | S1 | **P0** | 1.5 h | — | ✅ **已完成**(workflow + runner 重构 + exit code) |
| P4 | `data.js` 瘦身 | S1 | **P0** | 3 h | — | ✅ **已完成**(data.js 98KB/sources 395/data.json 完整/回归 1003/1003,见 c65a69c) |
| P5 | 暗色模式 | S2 | **P0** | 1 d | P4 ✅ | ✅ **已完成**(令牌组31/对比度10项≥4.5/10页实测/DARK VERIFY 32/32/首屏292KB/回归1003/1003,Q6选A) |
| P6 | 跨端一致性验证 | S2 | **P0** | 1 d | P3 | ⚪ 待 P3 |
| P6 | 跨端一致性验证 | S2 | **P0** | 1 d | P3 | ✅ **已完成**(静态结构比对 79/79,high 44/medium 22/low 11,F1-F4 已追加) |
| P7 | 行为断言 | S2 | P1 | 1.5 d | — | 🟢 可开工(优先级低于 P2-P4) |
| P8 | RTL | S3 | P1 | 2 d | P5 | ⚪ 待 P5 |
| P9 | FAQ 页 | S3 | P2 | 1 d | — | 🟢 可开工(优先级低) |
File diff suppressed because it is too large Load Diff
+329
View File
@@ -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);
}