#!/usr/bin/env node
/**
* verify-uniapp-build.mjs — uni-app 端**真实编译**验证(ROADMAP S7-P25)
*
* 与 tools/verify-uniapp.mjs 的分工(两者互补,都要跑):
* verify-uniapp.mjs 静态门禁:SFC 三段 / 语法 / 标签配平 / 禁 DOM API / 只用 uni 基础组件 / 前缀 / 单位
* verify-uniapp-build.mjs 本脚本:**真的调 uni-app 编译器**把 18(或 3)个 SFC 编译到 H5 与微信小程序
*
* 为什么必须做这件事(2026-09-20 首次执行):
* 静态门禁只能证明「源码长得合规」,证明不了「编译器接受它」。历史上本仓库有两次
* 「静态全绿但根本编译不过」的实例(`RangeQuickPicker` 的 const 重赋值、`CodeInput` 的 emit 遮蔽),
* 都是真编译才暴露的。uni-app 端的 18 个 SFC 此前**从未被编译器碰过** —— 本脚本补上这一环。
*
* 依赖处理(不污染主仓库):
* @dcloudio/* 装在**隔离目录**,仓库的 package.json 保持零运行时依赖。
* 依赖目录优先级:$KOLE_UNIAPP_DEPS_DIR > .tmp/uniapp-build/node_modules > 报错并打印安装命令。
*
* 用法:
* node tools/verify-uniapp-build.mjs # H5 + mp-weixin 两目标
* node tools/verify-uniapp-build.mjs --target=h5 # 只编译 H5(更快)
* node tools/verify-uniapp-build.mjs --keep # 保留临时工程(排查编译错误用)
*
* 首次准备(约 2 分钟,需联网;之后复用):
* mkdir -p .tmp/uniapp-build && cd .tmp/uniapp-build
* npm init -y && npm i -D @dcloudio/vite-plugin-uni@3.0.0-5020620260917001 \
* @dcloudio/uni-cli-shared@3.0.0-5020620260917001 vite@^5 vue@^3.4
* @dcloudio/uni-app@3.0.0-5020620260917001 @dcloudio/uni-h5@3.0.0-5020620260917001 \
* @dcloudio/uni-mp-weixin@3.0.0-5020620260917001 @dcloudio/uni-components@3.0.0-5020620260917001
*
* 退出码:0 全部通过;1 编译失败或产物断言失败;2 依赖缺失(环境问题,非代码问题)
*/
import {
readFileSync, writeFileSync, mkdirSync, existsSync, readdirSync, rmSync, cpSync,
} from 'node:fs';
import { execFileSync } from 'node:child_process';
import { fileURLToPath } from 'node:url';
import { dirname, join } from 'node:path';
const __dirname = dirname(fileURLToPath(import.meta.url));
const ROOT = join(__dirname, '..');
const read = (p) => readFileSync(p, 'utf8').replace(/^\uFEFF/, '');
/* ---------- 参数 ---------- */
const args = process.argv.slice(2);
const only = (args.find((a) => a.startsWith('--target=')) || '').split('=')[1]; // h5 | mp-weixin
const KEEP = args.includes('--keep');
const TARGETS = only ? [only] : ['h5', 'mp-weixin'];
let pass = 0;
const failures = [];
function check(ok, id, detail) {
if (ok) {
pass++;
console.log(` PASS ${id}${detail ? ' — ' + detail : ''}`);
} else {
failures.push(id + (detail ? ' — ' + detail : ''));
console.log(` FAIL ${id}${detail ? ' — ' + detail : ''}`);
}
}
/* ---------- 依赖定位(隔离目录,不碰主仓库 node_modules) ---------- */
function resolveDepsDir() {
const candidates = [
process.env.KOLE_UNIAPP_DEPS_DIR,
join(ROOT, '.tmp', 'uniapp-build'),
].filter(Boolean);
for (const c of candidates) {
if (existsSync(join(c, 'node_modules', '@dcloudio', 'vite-plugin-uni'))) return c;
}
return null;
}
const DEPS = resolveDepsDir();
if (!DEPS) {
console.error('[FATAL] 找不到 uni-app 工具链(@dcloudio/vite-plugin-uni)。');
console.error(' 这是环境问题,不是代码问题 —— 请先准备隔离的依赖目录:');
console.error(' mkdir -p .tmp/uniapp-build && cd .tmp/uniapp-build');
console.error(' npm init -y');
console.error(' npm i -D @dcloudio/vite-plugin-uni@3.0.0-5020620260917001 \\');
console.error(' @dcloudio/uni-cli-shared@3.0.0-5020620260917001 vite@^5 vue@^3.4 \\');
console.error(' @dcloudio/uni-app@3.0.0-5020620260917001 \\');
console.error(' @dcloudio/uni-h5@3.0.0-5020620260917001 \\');
console.error(' @dcloudio/uni-mp-weixin@3.0.0-5020620260917001 \\');
console.error(' @dcloudio/uni-components@3.0.0-5020620260917001');
console.error(' 或用 KOLE_UNIAPP_DEPS_DIR 指向已有目录。');
process.exit(2);
}
/* ---------- 待编译的 SFC(从两端索引读取,与静态门禁同一真源) ---------- */
/* 依赖版本从**已装的那份**读,而不是硬编码 —— 硬编码会在依赖升级后静默失配
(package.json 里写旧版本号时 npm/vite 解析不到,产物又变回空包) */
const DEPS_VERSION = JSON.parse(
read(join(DEPS, 'node_modules', '@dcloudio', 'vite-plugin-uni', 'package.json'))
).version;
const mbIndex = JSON.parse(read(join(ROOT, '.design_library', 'kole-ui-mobile', 'components', 'index.json')));
const mobileSfcs = mbIndex.components.map((c) => ({
slug: c.slug,
name: c.frameworksPrefix,
file: c.files.uniapp,
src: join(ROOT, 'frameworks-mobile', c.files.uniapp),
}));
const pcIndexPath = join(ROOT, '.design_library', 'kole-ui-uniapp', 'index.json');
const pcSfcs = existsSync(pcIndexPath)
? JSON.parse(read(pcIndexPath)).components.map((c) => ({
slug: c.slug,
/* 字段名是 frameworksPrefix(与移动端索引同惯例),不是 prefix —— 写错会让绑定名成 undefined
(踩坑记录:首次写成 c.prefix,21 个里 3 个绑定名是 undefined,模板标签 直接编译不过) */
name: c.frameworksPrefix,
file: c.file,
src: join(ROOT, 'frameworks-uniapp-pc', c.file),
}))
: [];
const all = [...mobileSfcs, ...pcSfcs];
/* 绑定名必须齐全且唯一 —— 缺名会生成 ``(编译不过),重名会互相覆盖(静默丢组件)。
真实冲突(2026-09-20 实测):移动端有 `Button`(kole-m-btn-*),PC×uni-app 也有 `Button`
(PC 的 btn-*)—— 两者是**不同平台的同名组件**,直接都用 `Button` 作绑定名会互相覆盖,
产物里整批组件的样式全部消失(JS 只剩 App.vue,704 字节)。
消歧规则:PC×uni-app 侧加 `Pc` 前缀(移动端保持原名,因为它是本仓库的主要交付面)。 */
const NAME_CONFLICTS = new Set();
{
const seen = new Map();
for (const s of all) {
const base = s.name;
if (seen.has(base)) NAME_CONFLICTS.add(base);
seen.set(base, s);
}
for (const s of all) {
if (NAME_CONFLICTS.has(s.name) && s.src.includes('frameworks-uniapp-pc')) {
s.binding = 'Pc' + s.name;
} else {
s.binding = s.name;
}
}
}
const names = all.map((s) => s.name);
const bindings = all.map((s) => s.binding);
const badNames = all.filter((s) => !s.binding || !/^[A-Z][A-Za-z0-9]*$/.test(s.binding)).map((s) => s.slug);
const dupBindings = [...new Set(bindings.filter((n, i) => bindings.indexOf(n) !== i))];
check(badNames.length === 0 && dupBindings.length === 0, 'B0b 组件绑定名合法且唯一',
badNames.length || dupBindings.length
? `非法 ${badNames.join(', ') || '无'} / 重名 ${dupBindings.join(', ') || '无'}`
: `${bindings.length} 个${NAME_CONFLICTS.size ? '(同名消歧:' + [...NAME_CONFLICTS].join(', ') + ' → PC 侧加 Pc 前缀)' : ''}`);
console.log(`uni-app 真实编译验证 | 待编译 ${all.length} 个 SFC(移动端 ${mobileSfcs.length} · PC ${pcSfcs.length})`);
console.log(`依赖目录:${DEPS}`);
console.log(`目标:${TARGETS.join(', ')}\n`);
/* ---------- 搭一个最小 uni-app 工程,把 SFC 全量引入并编译 ---------- */
const PROJ = join(ROOT, '.tmp', 'uniapp-probe');
if (existsSync(PROJ)) rmSync(PROJ, { recursive: true, force: true });
mkdirSync(join(PROJ, 'src', 'pages', 'index'), { recursive: true });
mkdirSync(join(PROJ, 'src', 'components'), { recursive: true });
/* 依赖走软链/复制:把隔离目录的 node_modules 挂到工程里(Windows 下用 junction) */
try {
execFileSync('cmd', ['/c', 'mklink', '/J', join(PROJ, 'node_modules'), join(DEPS, 'node_modules')], { stdio: 'pipe' });
} catch {
/* 非 Windows 或已存在:退回复制(慢但可行) */
cpSync(join(DEPS, 'node_modules'), join(PROJ, 'node_modules'), { recursive: true });
}
/* package.json 必须把 @dcloudio/* 声明进 dependencies(不能只放 devDependencies)——
踩坑记录(2026-09-20,本脚本调通前最深的一个坑):uni-app 编译器靠 package.json 里声明的
@dcloudio 依赖来**识别这是一个 uni-app 工程**。只写 `{name, private, version}` 时,
编译器照样打印 "DONE Build complete."、退出码 0,但产物只有一个 704 字节的
modulepreload polyfill —— **页面与 21 个组件全部静默丢失**。
这类失败「静态门禁」与「编译成功」都发现不了,只有「断言产物里必须出现组件类名」能抓住,
所以下面的 B-* 类名断言不是锦上添花,而是本脚本的核心价值。 */
writeFileSync(join(PROJ, 'package.json'), JSON.stringify({
name: 'kole-uniapp-probe',
private: true,
version: '0.0.0',
scripts: { 'build:h5': 'uni build -p h5', 'build:mp-weixin': 'uni build -p mp-weixin' },
dependencies: {
'@dcloudio/uni-app': DEPS_VERSION,
'@dcloudio/uni-components': DEPS_VERSION,
'@dcloudio/uni-h5': DEPS_VERSION,
'@dcloudio/uni-mp-weixin': DEPS_VERSION,
vue: '^3.4.21',
},
devDependencies: {
'@dcloudio/uni-cli-shared': DEPS_VERSION,
'@dcloudio/vite-plugin-uni': DEPS_VERSION,
vite: '^5.2.8',
},
}, null, 2) + '\n');
writeFileSync(join(PROJ, 'vite.config.js'),
"import { defineConfig } from 'vite';\nimport uni from '@dcloudio/vite-plugin-uni';\nexport default defineConfig({ plugins: [uni()] });\n");
writeFileSync(join(PROJ, 'index.html'),
'
Kole UI probe' +
'\n');
writeFileSync(join(PROJ, 'src', 'main.js'),
"import { createSSRApp } from 'vue';\nimport App from './App.vue';\nexport function createApp() {\n" +
" const app = createSSRApp(App);\n return { app };\n}\n");
writeFileSync(join(PROJ, 'src', 'App.vue'),
'Kole UI probe\n' +
'\n\n');
writeFileSync(join(PROJ, 'src', 'pages.json'), JSON.stringify({
pages: [{ path: 'pages/index/index', style: { navigationBarTitleText: 'Kole UI probe' } }],
}, null, 2) + '\n');
writeFileSync(join(PROJ, 'src', 'manifest.json'), JSON.stringify({
name: 'kole-uniapp-probe', appid: '', description: 'Kole UI uni-app compile probe',
versionName: '1.0.0', versionCode: '100', vueVersion: '3',
}, null, 2) + '\n');
/* 拷 SFC + 令牌层(令牌是组件样式依赖的外部变量源) */
/* 落盘文件名用**绑定名**(含消歧前缀):移动端 Button.vue 与 PC 侧 PcButton.vue 各自独立,
否则后拷的会把先拷的覆盖掉(同名不同源,静默丢一个组件)。 */
for (const s of all) cpSync(s.src, join(PROJ, 'src', 'components', s.binding + '.vue'));
const tokens = join(ROOT, 'dist', 'mobile', 'tokens', 'tokens.css');
if (existsSync(tokens)) cpSync(tokens, join(PROJ, 'src', 'tokens.css'));
/* 页面把每个组件都真实挂载一次 —— 只有被 import 且**在模板里被用到**的 SFC 才会走完整编译链。
踩坑记录(2026-09-20 首次运行):初版用 `` + `import C0 from …` 的索引式命名,
编译**通过但产物里 0 个组件**(JS 704 字节,只有 App.vue)——uni-app 的模板编译器要求
标签名与 import 绑定名可静态对应,`C0` 这类名字被当成未识别元素丢弃了。
改为「用组件自身的 Pascal 名作绑定名」后,21 个 SFC 全部进入产物(实测)。
这正是本脚本存在的意义:静态门禁与「编译成功」都发现不了这种整批静默丢弃。 */
const imports = all.map((s) => `import ${s.binding} from '../../components/${s.binding}.vue';`).join('\n');
const tags = all.map((s) => ` <${s.binding} />`).join('\n');
writeFileSync(join(PROJ, 'src', 'pages', 'index', 'index.vue'),
`\n \n${tags}\n \n\n\n` +
`\n\n` +
'\n');
check(all.every((s) => existsSync(s.src)), 'B0 待编译 SFC 全部存在于源目录',
`${all.length} 个(${all.filter((s) => !existsSync(s.src)).map((s) => s.file).join(', ') || '无缺失'})`);
/* ---------- 逐目标编译 ---------- */
function compile(target) {
const env = { ...process.env, UNI_PLATFORM: target, NODE_ENV: 'production' };
try {
const out = execFileSync(
process.execPath,
[join(PROJ, 'node_modules', '@dcloudio', 'vite-plugin-uni', 'bin', 'uni.js'), 'build', '-p', target],
{ cwd: PROJ, env, stdio: 'pipe', timeout: 300000, encoding: 'utf8' }
);
return { ok: true, out };
} catch (e) {
return { ok: false, out: String(e.stdout || '') + String(e.stderr || '') + String(e.message || '') };
}
}
const results = {};
for (const target of TARGETS) {
const t0 = Date.now();
const r = compile(target);
const ms = Date.now() - t0;
results[target] = r;
check(r.ok, `B-${target} 编译通过`, r.ok ? `${ms} ms` : r.out.split('\n').filter(Boolean).slice(-6).join(' / ').slice(0, 300));
if (!r.ok) continue;
const dist = join(PROJ, 'dist', 'build', target);
check(existsSync(dist), `B-${target} 产物目录存在`, dist.replace(ROOT + '\\', '').replace(ROOT + '/', ''));
/* 产物里必须出现 kole-m- 类名(证明 18 个组件的样式真进了产物,而不是被 tree-shake 掉) */
let clsHit = 0;
const walk = (dir) => {
for (const e of readdirSync(dir, { withFileTypes: true })) {
const p = join(dir, e.name);
if (e.isDirectory()) walk(p);
else if (/\.(css|wxss|js|wxml)$/.test(e.name)) {
const m = read(p).match(/kole-m-[a-z0-9-]+/g);
if (m) clsHit += new Set(m).size;
}
}
};
if (existsSync(dist)) walk(dist);
check(clsHit > 0, `B-${target} 产物含 kole-m- 类名`, `${clsHit} 个唯一类名`);
/* 「每个组件都真的进了产物」怎么判才**不可自证**?
两次变异测试的教训(2026-09-20):
① 从 `frameworks-mobile/.css` 取类名 —— uni-app 端样式是**内联**的,
改 uniapp 端不影响那份 `.css`,判据查的是另一份文件(假通过)。
② 从 uni-app 端源码取类名 —— 改名后产物与判据**同步变化**,两边一致所以照样 PASS
(实测:`kole-m-grid` 全量改名后产物里只有新名,判据取到的也是新名)。
最终判据:拿**契约**(`.design_library/kole-ui-mobile/components/.json` 的
`variantClasses`)里声明的类名去产物里找。契约与实现是两条独立的写入路径
(实现由人手写、契约由规格推导),所以「实现悄悄改名/丢组件」时契约不会跟着变 ——
这正是能让变异测试 FAIL 的原因。 */
const missing = [];
for (const s of mobileSfcs) {
if (!existsSync(s.src)) { missing.push(s.slug + '(缺 uni-app 端)'); continue; }
const ctPath = join(ROOT, '.design_library', 'kole-ui-mobile', 'components', s.slug + '.json');
if (!existsSync(ctPath)) { missing.push(s.slug + '(缺契约)'); continue; }
const vc = (JSON.parse(read(ctPath)).variantClasses) || {};
const declared = new Set();
for (const dim of Object.values(vc)) {
for (const arr of Object.values(dim || {})) {
for (const t of arr || []) if (typeof t === 'string' && t.startsWith('.')) declared.add(t.slice(1));
}
}
if (!declared.size) { missing.push(s.slug + '(契约无类名声明)'); continue; }
/* 只要**任一**契约声明的类名出现在产物里,就证明该组件的样式真的进了包 */
let hit = false;
const walk2 = (dir) => {
if (hit) return;
for (const e of readdirSync(dir, { withFileTypes: true })) {
const p = join(dir, e.name);
if (e.isDirectory()) walk2(p);
else if (/\.(css|wxss|js|wxml)$/.test(e.name)) {
const t = read(p);
for (const cls of declared) if (t.includes(cls)) { hit = true; return; }
}
}
};
if (existsSync(dist)) walk2(dist);
if (!hit) missing.push(s.slug + '(契约类名 ' + [...declared][0] + ' 未进产物)');
}
check(missing.length === 0, `B-${target} 每个移动端组件(按契约声明的类名)都进了产物`,
missing.length ? missing.slice(0, 5).join(', ') : `${mobileSfcs.length}/${mobileSfcs.length}`);
/* 小程序端不得出现 DOM API(静态门禁查的是源码,这里查的是**编译产物**) */
if (target === 'mp-weixin') {
let domHit = 0;
const walk3 = (dir) => {
for (const e of readdirSync(dir, { withFileTypes: true })) {
const p = join(dir, e.name);
if (e.isDirectory()) walk3(p);
else if (/\.(js|wxml)$/.test(e.name) && read(p).match(/\bdocument\.(createElement|querySelector|body)/)) domHit++;
}
};
if (existsSync(dist)) walk3(dist);
check(domHit === 0, 'B-mp-weixin 产物无 DOM 操作', domHit ? `${domHit} 个文件命中` : '0');
}
}
/* ---------- 收尾 ---------- */
if (!KEEP) rmSync(PROJ, { recursive: true, force: true });
console.log('\n' + '─'.repeat(60));
if (failures.length === 0) {
console.log(`[OK] uni-app 真实编译验证通过(${pass} 条断言 · ${all.length} 个 SFC · ${TARGETS.join(' + ')})`);
console.log(' 注:App 端(app-plus)需 HBuilderX 的云端打包,无法在 CI 命令行完成;');
console.log(' 本脚本覆盖 H5 与微信小程序两个可命令行编译的目标。');
process.exit(0);
} else {
console.log(`[FAIL] ${failures.length} 条断言失败(通过 ${pass} 条):`);
failures.forEach((f) => console.log(' - ' + f));
console.log(' 排查提示:加 --keep 保留临时工程(.tmp/uniapp-probe),可进目录手工跑 uni build 看完整报错。');
process.exit(1);
}