#!/usr/bin/env bun /** * 构建产物完整性检查脚本 * * 检查 Bun.build({ splitting: true }) 输出的 dist/ 目录中是否存在: * 1. 引用了不存在的 chunk 文件(断链) * 2. 通过 __require() 或 import() 引用的第三方模块(非 Node.js 内置),在生产环境中会找不到 * 3. 缺失的静态 import 依赖(跨 chunk 引用目标不存在) * * 用法: * bun scripts/check-bundle-integrity.ts # 检查当前 dist/ * bun scripts/check-bundle-integrity.ts ./dist # 指定目录 */ import { readdir, readFile } from 'fs/promises' import { join, resolve, dirname } from 'path' import { fileURLToPath } from 'url' // ─── 从 package.json 读取 dependencies 作为白名单 ──────────────── const __dirname = dirname(fileURLToPath(import.meta.url)) const pkg = JSON.parse( await readFile(join(__dirname, '..', 'package.json'), 'utf-8'), ) const PKG_DEPS = new Set(Object.keys(pkg.dependencies ?? {})) // ─── Node.js 内置模块白名单 ──────────────────────────────────────── const NODE_BUILTINS = new Set([ 'assert', 'async_hooks', 'buffer', 'child_process', 'cluster', 'console', 'constants', 'crypto', 'dgram', 'diagnostics_channel', 'dns', 'domain', 'events', 'fs', 'fs/promises', 'http', 'http2', 'https', 'inspector', 'module', 'net', 'os', 'path', 'perf_hooks', 'process', 'punycode', 'querystring', 'readline', 'repl', 'stream', 'string_decoder', 'sys', 'timers', 'tls', 'tty', 'url', 'util', 'v8', 'vm', 'worker_threads', 'zlib', 'node:test', ]) // Node 18+ 内置但不在传统列表中的模块 const NODE_18_PLUS_BUILTINS = new Set(['undici']) // Bun 专用模块(仅在 Bun 运行时可用,Node.js 环境会失败) const BUN_MODULES = new Set(['bun', 'bun:ffi', 'bun:test', 'bun:sqlite']) // macOS JXA / native 框架(通过 ObjC.import,非真正的 require) const NATIVE_FRAMEWORKS = new Set([ 'AppKit', 'CoreGraphics', 'Foundation', 'UIKit', ]) // ─── 模式 ────────────────────────────────────────────────────────── // 匹配 import { ... } from "./chunk-xxxxx.js" 或 import"./chunk-xxxxx.js" const STATIC_IMPORT_RE = /(?:from\s+|import\s+)"(\.\/[^"]+\.js)"/g // 匹配 __require("xxx") const REQUIRE_RE = /__require\("([^"]+)"\)/g // 匹配动态 import("xxx"),排除 ./chunk-xxx.js 的内部引用 const DYNAMIC_IMPORT_RE = /import\("([^"]+)"\)/g // 匹配 nodeRequire("xxx")(createRequire 创建的 require 别名) const NODE_REQUIRE_RE = /nodeRequire\("([^"]+)"\)/g interface Finding { type: | 'broken-chunk-ref' | 'third-party-require' | 'third-party-import' | 'third-party-node-require' | 'bun-runtime-only' severity: 'error' | 'warning' file: string line: number module: string snippet: string } async function main() { const distDir = resolve(process.argv[2] || './dist') console.log(`\n🔍 检查构建产物完整性: ${distDir}\n`) // 1. 列出所有 chunk 文件 let files: string[] try { files = (await readdir(distDir)).filter(f => f.endsWith('.js')) } catch { console.error(`❌ 无法读取目录: ${distDir}`) console.error(' 请先运行 bun run build') process.exit(1) } const fileSet = new Set(files) console.log(`📦 找到 ${files.length} 个 JS 文件\n`) const findings: Finding[] = [] // 2. 逐文件扫描 for (const file of files) { const filePath = join(distDir, file) const content = await readFile(filePath, 'utf-8') const lines = content.split('\n') for (let i = 0; i < lines.length; i++) { const line = lines[i] const lineNum = i + 1 // 2a. 检查静态 chunk 引用是否断链 const staticImportMatches = line.matchAll(STATIC_IMPORT_RE) for (const m of staticImportMatches) { const ref = m[1] // 提取文件名部分(去掉 ./) const refFile = ref.replace(/^\.\//, '') if (!fileSet.has(refFile)) { findings.push({ type: 'broken-chunk-ref', severity: 'error', file, line: lineNum, module: ref, snippet: line.trim().slice(0, 120), }) } } // 2b. 检查 __require 中的第三方模块 const requireMatches = line.matchAll(REQUIRE_RE) for (const m of requireMatches) { const mod = m[1] // 跳过 ObjC.import(JXA 语法,不是真正的 require) if (NATIVE_FRAMEWORKS.has(mod)) continue if ( NODE_BUILTINS.has(mod) || NODE_18_PLUS_BUILTINS.has(mod) || PKG_DEPS.has(mod) || mod.startsWith('node:') ) continue if (BUN_MODULES.has(mod)) { findings.push({ type: 'bun-runtime-only', severity: 'warning', file, line: lineNum, module: mod, snippet: line.trim().slice(0, 120), }) continue } // 第三方模块 — 在生产环境(全局 npm install)中找不到 findings.push({ type: 'third-party-require', severity: 'error', file, line: lineNum, module: mod, snippet: line.trim().slice(0, 120), }) } // 2c. 检查动态 import() 中的第三方模块 const dynImportMatches = line.matchAll(DYNAMIC_IMPORT_RE) for (const m of dynImportMatches) { const mod = m[1] // 跳过内部 chunk 引用和相对路径 if (mod.startsWith('./') || mod.startsWith('../')) continue // 跳过 ObjC.import if (NATIVE_FRAMEWORKS.has(mod)) continue if ( NODE_BUILTINS.has(mod) || NODE_18_PLUS_BUILTINS.has(mod) || PKG_DEPS.has(mod) || mod.startsWith('node:') ) continue if (BUN_MODULES.has(mod)) { // bun:test 等只在 Bun 运行时可用,Node.js 运行时会失败 findings.push({ type: 'bun-runtime-only', severity: 'warning', file, line: lineNum, module: mod, snippet: line.trim().slice(0, 120), }) continue } // 第三方动态 import findings.push({ type: 'third-party-import', severity: 'error', file, line: lineNum, module: mod, snippet: line.trim().slice(0, 120), }) } // 2d. 检查 nodeRequire("xxx") 中的第三方模块(createRequire 别名) const nodeRequireMatches = line.matchAll(NODE_REQUIRE_RE) for (const m of nodeRequireMatches) { const mod = m[1] if (NATIVE_FRAMEWORKS.has(mod)) continue if ( NODE_BUILTINS.has(mod) || NODE_18_PLUS_BUILTINS.has(mod) || PKG_DEPS.has(mod) || mod.startsWith('node:') ) continue if (BUN_MODULES.has(mod)) { findings.push({ type: 'bun-runtime-only', severity: 'warning', file, line: lineNum, module: mod, snippet: line.trim().slice(0, 120), }) continue } findings.push({ type: 'third-party-node-require', severity: 'error', file, line: lineNum, module: mod, snippet: line.trim().slice(0, 120), }) } } } // 3. 汇总报告 const errors = findings.filter(f => f.severity === 'error') const warnings = findings.filter(f => f.severity === 'warning') // 按 type 分组 const brokenRefs = errors.filter(f => f.type === 'broken-chunk-ref') const thirdPartyRequires = errors.filter( f => f.type === 'third-party-require', ) const thirdPartyImports = errors.filter(f => f.type === 'third-party-import') const thirdPartyNodeRequires = errors.filter( f => f.type === 'third-party-node-require', ) const bunRuntimeOnly = warnings.filter(f => f.type === 'bun-runtime-only') if (brokenRefs.length > 0) { console.log('❌ 断裂的 chunk 引用(引用了不存在的文件):') for (const f of brokenRefs) { console.log(` ${f.file}:${f.line} → ${f.module}`) } console.log() } if (thirdPartyRequires.length > 0) { console.log('❌ 通过 __require() 引用的第三方模块(生产环境会找不到):') const grouped = groupByModule(thirdPartyRequires) for (const [mod, items] of grouped) { console.log(` "${mod}" — 出现 ${items.length} 次:`) for (const f of items.slice(0, 5)) { console.log(` ${f.file}:${f.line}`) } if (items.length > 5) console.log(` ... 还有 ${items.length - 5} 处`) } console.log() } if (thirdPartyImports.length > 0) { console.log('❌ 通过 import() 动态引用的第三方模块(生产环境会找不到):') const grouped = groupByModule(thirdPartyImports) for (const [mod, items] of grouped) { console.log(` "${mod}" — 出现 ${items.length} 次:`) for (const f of items.slice(0, 5)) { console.log(` ${f.file}:${f.line}`) } if (items.length > 5) console.log(` ... 还有 ${items.length - 5} 处`) } console.log() } if (thirdPartyNodeRequires.length > 0) { console.log( '❌ 通过 nodeRequire() 引用的第三方模块(绕过打包,生产环境会找不到):', ) const grouped = groupByModule(thirdPartyNodeRequires) for (const [mod, items] of grouped) { console.log(` "${mod}" — 出现 ${items.length} 次:`) for (const f of items.slice(0, 5)) { console.log(` ${f.file}:${f.line}`) } if (items.length > 5) console.log(` ... 还有 ${items.length - 5} 处`) } console.log() } if (bunRuntimeOnly.length > 0) { console.log('⚠️ Bun 运行时专用模块(Node.js 环境会失败):') const grouped = groupByModule(bunRuntimeOnly) for (const [mod, items] of grouped) { console.log(` "${mod}" — 出现 ${items.length} 次`) } console.log() } // 4. 总结 console.log('─'.repeat(50)) if (errors.length === 0 && warnings.length === 0) { console.log('✅ 构建产物完整性检查通过,未发现问题。') } else { console.log(`📊 总计: ${errors.length} 个错误, ${warnings.length} 个警告`) if (errors.length > 0) { console.log( `\n💡 修复建议: - 第三方模块问题:在 build.ts 中通过 external 选项排除,或确保它们被正确打包到 chunk 中 - 断链问题:检查 build 时是否有文件被意外删除或构建不完整 - Bun 专用模块:确保运行时使用 bun 而非 node`, ) } } process.exit(errors.length > 0 ? 1 : 0) } function groupByModule(items: Finding[]): Map { const map = new Map() for (const item of items) { const list = map.get(item.module) || [] list.push(item) map.set(item.module, list) } // 按出现次数降序 return new Map([...map.entries()].sort((a, b) => b[1].length - a[1].length)) } main().catch(err => { console.error('Fatal error:', err) process.exit(2) })