#!/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'), '\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` + `\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); }