test(toolchain): [MOD-01] evaluate isolated Bun installs and retain Yarn

This commit is contained in:
Harvey Zhao committed 2026-09-15 01:14:46 +08:00
1 parent ddf3d6ccbc
commit 18215b97c0
14 files changed
+2401 -5

No files matched your search

+2 -1
View File
@@ -158,7 +158,8 @@
"test:rollback:mainline": "node scripts/mainline-rehearsal.mjs",
"check:rollback-inventory": "node refactor/scripts/rollback-inventory.mjs --check",
"test:rollback:pages": "node scripts/pages-recovery.mjs",
"test:rollback:pages:browser": "node test/pages-recovery-browser.mjs"
"test:rollback:pages:browser": "node test/pages-recovery-browser.mjs",
"test:bun-install": "node refactor/scripts/bun-evaluation.mjs"
},
"browserslist": "last 1 Chrome version",
"devDependencies": {
+1
View File
@@ -33,6 +33,7 @@
| [逐包恢复清单](rollback-inventory.md) | 全包精确旧版本、依赖及完整回退产物的缺口 |
| [工具链与发布](toolchain-release.md) | TypeScript、Bun、构建、版本管理和发布回退 |
| [已实现的开发环境](toolchain-setup.md) | Node/Yarn 固定版本、锁文件、安装命令和实际验证范围 |
| [Bun 隔离安装评估](bun-evaluation.md) | 固定版本对比、传递依赖/资源差异及继续使用 Yarn 的依据 |
| [全包大版本策略](version-policy.md) | 每包分别升级一个 major 的目标清单、兼容要求和版本落地步骤 |
| [已实现的 CI 入口](ci-setup.md) | Yarn 检查/构建、只读 lint、Pages 隔离及远端待验收状态 |
| [GitHub CI/CD](github-ci-cd.md) | PR/兼容矩阵、构建报告、Pages、npm 发布及远端准入验证 |
File diff suppressed because it is too large. Load diff
+100
View File
@@ -0,0 +1,100 @@
# Bun 隔离安装评估
MOD-01 的结论是继续使用 Yarn Classic 1.22.22。Bun 1.4.2 的普通迁移可以安装、
执行现有 Node 单元测试和完整构建,但首次冻结迁移失败,传递依赖及部分资源与
Yarn 冻结基线不同。本评估不采用 Bun,不新增受维护的 bun.lock,也不替换运行时
或 bundler。未来重测版本时重新建立证据,不把这次结论外推到其他 Bun 版本。
## 环境与来源
- Windows x64,Node 24.21.0,Yarn 1.22.22,Bun 1.4.2+744846f84。
- 三份独立、仓库外的 Git detached checkout,同一提交
`ddf3d6ccbc2fa7a3fa1a25c2b935b9d7045cee5c`,各有自己的 node_modules。
- Bun 官方 [1.4.2 发布归档](https://github.com/oven-sh/bun/releases/tag/bun-v1.4.2)
`bun-windows-x64.zip`:39,807,510 字节,SHA-256
`ce4c17497b2f29712a99d3d53f028de28cd42e3bacb8589599e7f000e49b6405`。
下载前记录 GitHub release asset digest,下载后比对,再解压到忽略的工具缓存。
没有升级全局 Bun 1.3.14,也没有把二进制加入生产依赖。
- 安装钩子正常启用;只排除 Playwright 浏览器下载,并清空 NODE_PATH。
历史契约测试使用复制的冻结 releases 夹具,不复制已有 node_modules。
- 初步在仓库内建的 checkout 可能借用祖先依赖,发现后保留日志,改用仓库外目录。
正式 Yarn 安装复用初步运行的下载缓存;Bun 首次安装是冷缓存。因此耗时仅作
执行记录,不能据此宣传安装速度差异。
## 结果
| 检查 | Yarn | Bun |
| --- | --- | --- |
| 从 yarn.lock 初次冻结安装 | 通过 | 失败:lockfile had changes, but lockfile is frozen |
| 另一干净目录普通迁移 | 不适用 | 通过,生成仅存于评估目录的 bun.lock |
| 生成 bun.lock 后冻结复装 | 不适用 | 通过,锁文件与完整审计依赖图未变 |
| Node 单元测试 | 2,906 通过,0 跳过 | 2,906 通过,0 跳过 |
| `yarn ci:build` | 通过 | 在 Bun 安装的依赖上通过,仍由 Yarn/Node 执行 |
| `yarn typecheck` | 415 个 TS 生产文件及类型消费者通过 | 同样通过,含 TS 5.9.3 / 4.3.5 / 5.1.6 矩阵 |
| 根工具依赖版本 | 与 manifest 固定版本一致 | 与 Yarn 一致 |
| 依赖实例 / 唯一 name@version | 1,229 / 1,125 | 1,439 / 1,135 |
| 审计的唯一原生/worker/WASM 资源 | 92 | 90 |
| 未解析必需的非 peer 依赖 | 0 | 0 |
物理提升方式不同不直接判为不兼容。比较脚本另外按父包版本、依赖字段、请求范围
和实际解析版本去重比较,保留未满足 peer/平台可选依赖。Bun 为旧搜索插件安装了
flexsearch 0.7.43 peer;Yarn 对应位置解析到 0.8.158。Algolia 从 5.48.0 变为
5.59.0;Vite 5.4.21 的 Rollup 从 4.57.1 变为 4.63.3。这些是实测解析差异,
不表示所有依赖升级都是缺陷,也不能以单元测试通过替代依赖等价。
`bun pm untrusted` 明确列出 less 4.5.1 与 nx 20.8.4 的 postinstall 未运行。
没有使用全量 trust 绕过差异。根 postinstall 中的 npx 被 Bun 执行为 bun x,
Lerna 8.2.4 报告无 workspace prepare 脚本。两者的默认脚本策略不相同,参见
[Bun lifecycle 文档](https://bun.com/docs/pm/lifecycle)。
完整构建包含 21 个库、核心声明/i18n、编辑器声明、站点资源、文档站及五项导入
测试。对各包 dist/types 与 docs 的 compiled/document/assets-ts/assets-js
核对精确字节:两边各 534 文件,448 文件相同;29 个同路径文件改变,另有两边
各 57 个独有文件名,全部在 docs/document。库、声明与其余被检查输出相同。
不做文本归一化,不把带不同哈希的站点文件名当作丢失;此对照也未单独排除所有
文档构建非确定性因素,因此不把每个差异都归因于某个具体依赖。
安装资源审计覆盖依赖目录内的 .node/.exe/.wasm 与 worker/worklet JS 文件,
不等于所有平台二进制或所有包资源。此任务没有进行 Bun 产物的浏览器、物理设备
或 npm 发布验收;既已决定不采用,不建立它作为第二套受支持 CI 环境。
## 复跑与维护
先使用 `.node-version` 的 Node 与固定 Yarn。准备阶段读取 HEAD,不包含未提交
代码;因此比较生产内容前应先提交对应任务。命令级固定 Git 换行设置,不改本机
或仓库 Git 配置。准备命令会输出评估目录,并写入 `.cache/bun-evaluation/latest.json`。
```text
yarn test:bun-install prepare
yarn test:bun-install yarn <run-directory>
yarn test:bun-install bun-frozen <run-directory> <bun-executable>
yarn test:bun-install bun-migrate <run-directory> <bun-executable>
node refactor/scripts/install-graph.mjs <yarn-checkout> <run-directory>/yarn-graph.json
node refactor/scripts/install-graph.mjs <bun-migrate-checkout> <run-directory>/bun-graph.json
node refactor/scripts/install-compare.mjs <run-directory>
```
每阶段日志使用独占创建,不能覆盖后重试。初次安装要求无 node_modules;失败的
冻结迁移和普通迁移使用不同 checkout。可通过 ARTPLAYER_EVAL_CACHE 显式复用
评估目录下的缓存,脚本记录实际冷/热状态。各 checkout 内依次运行同一组
`yarn test:unit`、`yarn ci:build`、`yarn typecheck`;需要历史归档时只复制
`refactor/.cache/releases`,不要复制 node_modules。类型检查要在构建完成后运行。
```text
node refactor/scripts/install-build-compare.mjs <run-directory>
yarn test:bun-install bun-repeat <run-directory> <bun-executable>
node refactor/scripts/install-graph.mjs <bun-migrate-checkout> <run-directory>/bun-repeat-graph.json
node --test refactor/scripts/install-graph.test.mjs
```
- bun-evaluation.mjs:固定版本、checkout/缓存边界、安装进程、锁文件保护与日志。
- install-graph.mjs:真实依赖解析、资源指纹;拒绝逃出 checkout 的依赖链接。
包名为 buffer/punycode 等 Node 内置同名 npm 包也纳入审计。
- install-compare.mjs:语义依赖/peer/资源差异;不将提升路径不同当成版本变化。
- install-build-compare.mjs:相同命令构建后的产物清单与逐字节差异。
- install-graph.test.mjs:防止从父目录借依赖、同名内置模块漏报、嵌套解析与资源审计。
评估 checkout、缓存及原始失败报告保留在本机忽略目录,可能需要数 GB 空间。
清理时先核对 snapshot.json 的 checkoutRoot、Git worktree 列表和实际绝对路径,
使用 Git worktree 管理及原生文件操作;不能删除本仓库或其他任务的目录。
正式证据索引见 [机器记录](baselines/bun-install-validation.json)。
@@ -0,0 +1,33 @@
# MOD-01 Bun 安装评估:保留 Yarn
已完成固定 Bun 1.4.2 的仓库外安装评估,结论是不采用。公开 API、生产源码、
根 yarn.lock、Node 运行时与 bundler 均未改变;新增 test:bun-install 和四个职责
独立的审计/比较脚本,模块地图与复跑步骤见 [维护文档](../bun-evaluation.md)。
## 实际验证
源提交 ddf3d6ccbc2fa7a3fa1a25c2b935b9d7045cee5c,Windows x64,Node 24.21.0、
Yarn 1.22.22、Bun 1.4.2。首次 Bun 冻结迁移因锁变更失败,报告保留;另一个
干净目录的普通迁移及生成锁后的冻结复装通过,后者依赖图/锁未变。
两边独立安装后均通过 2,906 项 Node 单元测试、415 个 TS 生产文件及公开/精确
消费者检查、完整 ci:build(含 21 库、声明、i18n、文档及五项导入测试)。
根固定工具相同,但 Algolia/Rollup 等传递解析、peer 与安装脚本策略不等价。
资源审计 92 对 90 个;构建清单各 534 文件中 448 完全相同,29 同路径改变、
两边各 57 独有路径均在文档站。库输出/声明相同不代替整套工具链等价。
新增两项审计回归通过,覆盖借用祖先 node_modules、内置同名 npm 包、嵌套依赖、
可选 peer 与资源指纹。修正审计器把 buffer 等误当内置模块漏报的问题,之后重新
生成两套图;不将修正前的包数量用于正式结论。scoped ESLint、严格工具链及计划
检查通过。完整日志路径与 SHA-256 见 [机器证据](../baselines/bun-install-validation.json)。
## 边界与恢复
初步 checkout 位于仓库内,发现可能借用父目录依赖后改用 OS 临时目录;初步日志
保留。正式 Yarn 复用下载缓存,Bun 初装为冷缓存,不提供速度提升比例。安装依赖
钩子没有全部禁用,也没有对 Bun 屏蔽的 less/nx 钩子强制 trust。没有改变全局 Bun。
此任务只作安装采用决策,不声称 Bun 浏览器、跨平台、真机或 npm 发布验收通过。
仍由 MOD-05 汇总工具链与性能决策。移除本任务脚本及文档即可撤销工程改动;
隔离 checkout/缓存清理须按维护文档核对归属,不影响工作区现有 node_modules。
任务独立本地提交后运行提交审计;未推送、部署或发布。
+3 -2
View File
@@ -4,7 +4,7 @@
基线:`40fcda6a37d0049d42e49c1e64e70d4fd9ba5f7f`。总任务 262 项,范围 22 个包及工作区/示例。
状态:todo 45 / doing 22 / blocked 0 / done 195 / deferred 0。风险 L/M/H 表示兼容风险,不表示工期。
状态:todo 44 / doing 22 / blocked 0 / done 196 / deferred 0。风险 L/M/H 表示兼容风险,不表示工期。
前置依赖是启动条件;验收是完成条件。任务可以继续拆分,但不能复用或悄悄删除旧 ID。
@@ -414,7 +414,7 @@
| ID | 范围 / 步骤 | 前置依赖 | 交付物 | 验收条件 | 风险 | 状态 |
| --- | --- | --- | --- | --- | --- | --- |
| MOD-01 | workspace<br>Bun 固定版本干净安装试点 | ENG-09 | 独立目录的 Bun 安装与 Yarn 冻结基线对比,默认仍为用户选定的 Yarn | Node 测试仍通过;安装与资源一致才决定采用;不改 bundler;不自动替换默认 packageManager 或维护锁文件 | H | todo |
| MOD-01 | workspace<br>Bun 固定版本干净安装试点 | ENG-09 | 独立目录的 Bun 安装与 Yarn 冻结基线对比,默认仍为用户选定的 Yarn | Node 测试仍通过;安装与资源一致才决定采用;不改 bundler;不自动替换默认 packageManager 或维护锁文件 | H | done |
| MOD-PLUGIN-01 | workspace<br>迁移插件生成器与类型化模板 | ENG-06, SITE-03 | 保留 create:plugin CLI;纯渲染/文件发布边界、TS 工厂/声明/测试/示例及维护指南 | 防止覆盖现有包或示例;失败回退;生成包可构建并通过严格类型、旧模块消费及测试;不自动修改既有包或运行安装发布 | M | done |
| MOD-02 | workspace<br>整理剩余开发/构建脚本与插件模板 | ENG-06, SITE-03, MOD-PLUGIN-01 | dev/build/utils/create-plugin 的 TS 与可测 CLI,模板同时提供旧 API | 旧脚本入口保留、新插件类型/测试/示例齐全,Lerna 改动单独取证 | M | done |
| MOD-DEV-01 | workspace<br>修复开发服务器失败状态并明确资源生命周期 | MOD-02 | 修复 Servor 占用端口却退出 0 的已复现问题;明确 HTTP/监听/热刷新连接与定时器的启动、失败和关闭责任 | 保持默认 8082、docs 页面、自动刷新和旧 dev 命令;端口冲突退出非零且不终止其他服务,连续启动/停止及编译错误恢复有实际验证;跨平台监听差异单独留证 | M | done |
@@ -653,6 +653,7 @@
- SITE-07: [记录](site-inventory.md) [记录](baselines/site-provenance.json) [记录](changes/2026-09-14-SITE-07-vendor-notices.md) [记录](baselines/site-notices-checkpoint.json)
- EX-01: [记录](changes/2026-09-14-EX-01-react-consumer.md) [记录](baselines/react-consumer-validation.json) [记录](scripts/react-consumer.mjs)
- EX-02: [记录](changes/2026-09-14-EX-02-vue-consumer.md) [记录](baselines/vue-consumer-validation.json) [记录](scripts/vue-consumer.mjs)
- MOD-01: [记录](changes/2026-09-15-MOD-01-bun-evaluation.md) [记录](baselines/bun-install-validation.json) [记录](bun-evaluation.md)
- MOD-PLUGIN-01: [记录](changes/2026-09-14-MOD-PLUGIN-01-scaffold.md) [记录](baselines/scaffold-validation.json)
- MOD-02: [记录](changes/2026-09-14-MOD-02-library-tooling.md) [记录](baselines/library-tooling-validation.json)
- MOD-DEV-01: [记录](changes/2026-09-14-MOD-DEV-01-dev-server.md) [记录](baselines/dev-server-validation.json)
+9
View File
@@ -1,5 +1,14 @@
# 进度与证据
## MOD-01 Bun 隔离安装评估完成,保留 Yarn
固定 Bun 1.4.2 与 Yarn 1.22.22 在仓库外同提交安装。Bun 初次冻结迁移失败,
普通迁移与随后冻结复装通过;传递依赖和安装脚本策略仍与 Yarn 不同,因此不采用。
两边 2,906 项单元测试、415 个 TS 源文件/类型消费者、完整构建均通过。库输出
与声明一致,文档站存在精确字节差异;不把测试通过当成安装等价。新增可复跑脚本、
审计负例与[记录](changes/2026-09-15-MOD-01-bun-evaluation.md)。默认仍是 Yarn,
根锁不变,未切换运行时/bundler;262 项现为 196 done、22 doing、44 todo。
## REL-04 完整旧站点恢复检查点
修正 Git archive 的本机换行影响:原 ZIP 有 403 处 CRLF 差异,保留失败证据;
+82
View File
@@ -0,0 +1,82 @@
import assert from 'node:assert/strict'
import { execFileSync, spawnSync } from 'node:child_process'
import fs from 'node:fs'
import os from 'node:os'
import path from 'node:path'
import process from 'node:process'
import { fileURLToPath } from 'node:url'
import { hash } from './releases.mjs'
const root = fileURLToPath(new URL('../../', import.meta.url))
const parent = path.join(root, 'refactor/.cache/bun-evaluation')
const read = file => JSON.parse(fs.readFileSync(file, 'utf8'))
const write = (file, value) => fs.writeFileSync(file, `${JSON.stringify(value, null, 2)}\n`)
assert.equal(process.versions.node, fs.readFileSync(path.join(root, '.node-version'), 'utf8').trim())
const phase = process.argv[2]
assert(['prepare', 'yarn', 'bun-frozen', 'bun-migrate', 'bun-repeat'].includes(phase), 'Use prepare, yarn, bun-frozen, bun-migrate or bun-repeat')
fs.mkdirSync(parent, { recursive: true })
if (phase === 'prepare') {
assert.equal(process.argv.length, 3)
const output = fs.mkdtempSync(path.join(parent, 'run-'))
const checkoutRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'artplayer-install-'))
assert(!path.resolve(checkoutRoot).startsWith(path.resolve(root)), 'Install checkouts must stay outside the repository')
const source = execFileSync('git', ['rev-parse', 'HEAD'], { cwd: root, encoding: 'utf8', windowsHide: true }).trim()
const snapshot = { task: 'MOD-01', source, createdAt: new Date().toISOString(), node: process.versions.node, checkoutRoot, scope: 'Detached checkouts outside the repository with independent node_modules; default Yarn unchanged', rootFilesAtPreparation: {}, checkouts: {}, sourceFiles: {} }
for (const file of ['package.json', 'yarn.lock']) snapshot.rootFilesAtPreparation[file] = hash(fs.readFileSync(path.join(root, file)))
for (const name of ['yarn', 'bun-frozen', 'bun-migrate']) {
const checkout = path.join(checkoutRoot, name)
const log = execFileSync('git', ['-c', 'core.autocrlf=false', '-c', 'core.eol=lf', 'worktree', 'add', '--detach', checkout, source], { cwd: root, encoding: 'utf8', windowsHide: true })
fs.writeFileSync(path.join(output, `${name}-checkout.log`), log)
assert(!fs.existsSync(path.join(checkout, 'node_modules')))
snapshot.checkouts[name] = checkout
snapshot.sourceFiles[name] = Object.fromEntries(['package.json', 'yarn.lock'].map(file => [file, hash(fs.readFileSync(path.join(checkout, file)))]))
}
write(path.join(output, 'snapshot.json'), snapshot)
write(path.join(parent, 'latest.json'), { output: path.relative(root, output).replaceAll('\\', '/') })
console.log(`Prepared isolated install comparison: ${output}`)
}
else {
const output = fs.realpathSync(path.resolve(root, process.argv[3] || ''))
assert.equal(path.dirname(output), fs.realpathSync(parent), 'Use a direct evaluation directory')
const snapshot = read(path.join(output, 'snapshot.json'))
const checkoutPhase = phase === 'bun-repeat' ? 'bun-migrate' : phase
const checkout = fs.realpathSync(snapshot.checkouts[checkoutPhase])
assert.equal(path.dirname(checkout), fs.realpathSync(snapshot.checkoutRoot))
assert(!checkout.startsWith(fs.realpathSync(root) + path.sep), 'Install checkout must not inherit the original node_modules')
assert.equal(path.basename(checkout), checkoutPhase)
if (phase === 'bun-repeat')
assert(fs.existsSync(path.join(checkout, 'bun.lock')), 'Repeat requires the generated Bun lock')
else
assert(!fs.existsSync(path.join(checkout, 'node_modules')), 'Initial installation requires a clean checkout')
const executable = phase === 'yarn' ? process.execPath : fs.realpathSync(process.argv[4] || '')
const yarn = process.env.npm_execpath
if (phase === 'yarn') {
assert.equal(process.env.npm_config_user_agent?.split(' ')[0], 'yarn/1.22.22', 'Run through pinned Yarn')
assert(yarn && fs.existsSync(yarn))
}
else {
assert.equal(execFileSync(executable, ['--version'], { encoding: 'utf8', windowsHide: true }).trim(), '1.4.2', 'This evaluation pins Bun 1.4.2')
}
const cache = process.env.ARTPLAYER_EVAL_CACHE ? fs.realpathSync(process.env.ARTPLAYER_EVAL_CACHE) : path.join(output, `${phase}-cache`)
assert(path.resolve(cache).startsWith(fs.realpathSync(parent) + path.sep), 'Keep install caches inside the evaluation area')
const coldPackageCache = !fs.existsSync(cache) || fs.readdirSync(cache).length === 0
const args = phase === 'yarn'
? [yarn, 'install', '--frozen-lockfile', '--non-interactive', '--cache-folder', cache]
: ['install', ...(['bun-frozen', 'bun-repeat'].includes(phase) ? ['--frozen-lockfile'] : []), '--cache-dir', cache, '--backend=copyfile']
const guardedFiles = ['package.json', 'yarn.lock', ...(phase === 'bun-repeat' ? ['bun.lock'] : [])]
const identities = Object.fromEntries(guardedFiles.map(file => [file, hash(fs.readFileSync(path.join(checkout, file)))]))
const start = performance.now()
const log = fs.openSync(path.join(output, `${phase}-install.log`), 'wx')
let result
try {
result = spawnSync(executable, args, { cwd: checkout, encoding: 'utf8', stdio: ['ignore', log, log], timeout: 600000, windowsHide: true, env: { ...process.env, NODE_PATH: '', PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD: '1', BUN_TELEMETRY: '0' } })
}
finally { fs.closeSync(log) }
const after = Object.fromEntries(Object.keys(identities).map(file => [file, hash(fs.readFileSync(path.join(checkout, file)))]))
assert.deepEqual(after, identities, 'Evaluation modified source manifest or Yarn lock')
const record = { task: 'MOD-01', phase, source: snapshot.source, node: process.versions.node, executable, args, durationMs: performance.now() - start, exitCode: result.status, signal: result.signal, error: result.error?.message, sourceFilesUnchanged: true, bunLock: fs.existsSync(path.join(checkout, 'bun.lock')) ? hash(fs.readFileSync(path.join(checkout, 'bun.lock'))) : null, log: `${phase}-install.log`, coldPackageCache, browserDownloadExcluded: true }
write(path.join(output, `${phase}-install.json`), record)
console.log(JSON.stringify(record))
process.exitCode = result.status ?? 1
}
@@ -0,0 +1,38 @@
import assert from 'node:assert/strict'
import fs from 'node:fs'
import path from 'node:path'
import process from 'node:process'
import { hash } from './releases.mjs'
const output = path.resolve(process.argv[2])
const snapshot = JSON.parse(fs.readFileSync(path.join(output, 'snapshot.json'), 'utf8'))
function inventory(root) {
const files = {}
function visit(relative) {
const file = path.join(root, relative)
const stat = fs.lstatSync(file)
assert(!stat.isSymbolicLink(), `Unexpected generated link: ${relative}`)
if (stat.isDirectory()) {
for (const name of fs.readdirSync(file)) visit(`${relative}/${name}`)
}
else {
files[relative] = { bytes: stat.size, sha256: hash(fs.readFileSync(file)) }
}
}
for (const name of fs.readdirSync(path.join(root, 'packages'))) {
for (const directory of ['dist', 'types']) {
const relative = `packages/${name}/${directory}`
if (fs.existsSync(path.join(root, relative)))
visit(relative)
}
}
for (const relative of ['docs/compiled', 'docs/document', 'docs/assets/ts', 'docs/assets/js']) visit(relative)
return files
}
const yarn = inventory(snapshot.checkouts.yarn)
const bun = inventory(snapshot.checkouts['bun-migrate'])
const files = [...new Set([...Object.keys(yarn), ...Object.keys(bun)])].sort()
const differences = files.filter(file => yarn[file]?.sha256 !== bun[file]?.sha256).map(file => ({ file, yarn: yarn[file] || null, bun: bun[file] || null }))
const report = { task: 'MOD-01', source: snapshot.source, command: 'yarn ci:build under Node for both installed dependency sets', scope: 'All package dist/types and docs compiled/document/assets-ts/assets-js; exact bytes with no normalization', counts: { yarn: Object.keys(yarn).length, bun: Object.keys(bun).length, identical: files.length - differences.length }, differences, inventories: { yarn, bun } }
fs.writeFileSync(path.join(output, 'build-comparison.json'), `${JSON.stringify(report, null, 2)}\n`)
console.log(JSON.stringify({ counts: report.counts, differences: differences.map(item => item.file) }, null, 2))
+37
View File
@@ -0,0 +1,37 @@
import assert from 'node:assert/strict'
import fs from 'node:fs'
import path from 'node:path'
import process from 'node:process'
const directory = path.resolve(process.argv[2])
const read = name => JSON.parse(fs.readFileSync(path.join(directory, name), 'utf8'))
const yarn = read('yarn-graph.json')
const bun = read('bun-graph.json')
const unique = values => [...new Set(values)].sort()
function differences(left, right) {
const a = new Set(left)
const b = new Set(right)
return { yarnOnly: [...a].filter(value => !b.has(value)).sort(), bunOnly: [...b].filter(value => !a.has(value)).sort() }
}
const tools = graph => Object.fromEntries(graph.rootTools.map(edge => [edge.name, edge.resolved]))
const leftTools = tools(yarn)
const rightTools = tools(bun)
assert.deepEqual(Object.keys(leftTools).sort(), Object.keys(rightTools).sort())
const rootToolChanges = Object.keys(leftTools).filter(name => leftTools[name] !== rightTools[name]).map(name => ({ name, yarn: leftTools[name], bun: rightTools[name] }))
const edges = graph => unique(graph.requests.map(edge => `${edge.parent} ${edge.field} ${edge.name}@${edge.range} -> ${edge.resolved}`))
const resources = graph => unique(graph.resources.map(item => `${item.package} ${item.path} ${item.bytes} ${item.sha256}`))
const missing = graph => unique(graph.missing.map(edge => `${edge.parent} ${edge.field} ${edge.name}@${edge.range} optional=${edge.optional}`))
const report = {
task: 'MOD-01',
source: read('snapshot.json').source,
counts: Object.fromEntries([['yarn', yarn], ['bun', bun]].map(([name, graph]) => [name, { packageInstances: graph.packages.length, uniquePackages: unique(graph.packages.map(pkg => `${pkg.name}@${pkg.version}`)).length, resolvedEdges: graph.requests.length, uniqueResources: resources(graph).length }])),
rootToolChanges,
packages: differences(yarn.packages.map(pkg => `${pkg.name}@${pkg.version}`), bun.packages.map(pkg => `${pkg.name}@${pkg.version}`)),
requests: differences(edges(yarn), edges(bun)),
missing: differences(missing(yarn), missing(bun)),
resources: differences(resources(yarn), resources(bun)),
missingRequired: Object.fromEntries([['yarn', yarn], ['bun', bun]].map(([name, graph]) => [name, graph.missing.filter(edge => !edge.optional && edge.field !== 'peerDependencies')])),
note: 'Physical hoisting differences are excluded from semantic comparisons. Missing optional/platform and peer requests remain explicit; this report alone does not determine build compatibility.',
}
fs.writeFileSync(path.join(directory, 'comparison.json'), `${JSON.stringify(report, null, 2)}\n`)
console.log(JSON.stringify({ counts: report.counts, rootToolChanges, resources: report.resources, missingRequired: report.missingRequired }, null, 2))
+85
View File
@@ -0,0 +1,85 @@
import assert from 'node:assert/strict'
import fs from 'node:fs'
import { createRequire } from 'node:module'
import path from 'node:path'
import process from 'node:process'
import { fileURLToPath } from 'node:url'
import { hash } from './releases.mjs'
export function installedGraph(checkout) {
const root = fs.realpathSync(checkout)
const packages = new Map()
const requests = []
const missing = []
function manifestAt(location) {
const actual = fs.realpathSync(location)
assert(actual.startsWith(root + path.sep), 'Installed dependency escaped isolated checkout')
return { directory: actual, manifest: JSON.parse(fs.readFileSync(path.join(actual, 'package.json'), 'utf8')) }
}
function resolve(from, name) {
const require = createRequire(path.join(from, 'package.json'))
// Audit npm packages even when their names also identify Node builtins.
for (const location of require.resolve.paths('artplayer-install-audit-probe')) {
const directory = path.join(location, name)
if (fs.existsSync(path.join(directory, 'package.json')))
return manifestAt(directory)
}
return null
}
function visit(directory, manifest, workspace = false) {
const key = path.relative(root, directory).replaceAll('\\', '/') || '.'
if (packages.has(key))
return
packages.set(key, { path: key, name: manifest.name, version: manifest.version, license: manifest.license || null, workspace, lifecycle: Object.fromEntries(Object.entries(manifest.scripts || {}).filter(([name]) => ['preinstall', 'install', 'postinstall', 'prepare'].includes(name))) })
const fields = workspace ? ['dependencies', 'devDependencies', 'optionalDependencies', 'peerDependencies'] : ['dependencies', 'optionalDependencies', 'peerDependencies']
for (const field of fields) {
for (const [name, range] of Object.entries(manifest[field] || {})) {
const dependency = resolve(directory, name)
const edge = { from: key, parent: `${manifest.name}@${manifest.version}`, field, name, range, optional: field === 'optionalDependencies' || (field === 'peerDependencies' && manifest.peerDependenciesMeta?.[name]?.optional === true) }
if (!dependency) {
missing.push(edge)
continue
}
requests.push({ ...edge, resolved: `${dependency.manifest.name}@${dependency.manifest.version}`, to: path.relative(root, dependency.directory).replaceAll('\\', '/') })
visit(dependency.directory, dependency.manifest)
}
}
}
const manifest = JSON.parse(fs.readFileSync(path.join(root, 'package.json'), 'utf8'))
visit(root, manifest, true)
for (const name of fs.readdirSync(path.join(root, 'packages'))) {
const directory = path.join(root, 'packages', name)
if (fs.existsSync(path.join(directory, 'package.json'))) {
const entry = manifestAt(directory)
// A workspace first reached through a runtime dependency still needs its dev/peer audit.
packages.delete(path.relative(root, directory).replaceAll('\\', '/'))
visit(directory, entry.manifest, true)
}
}
const resources = []
for (const pkg of packages.values()) {
if (pkg.workspace)
continue
const directory = path.join(root, pkg.path)
function walk(folder) {
for (const entry of fs.readdirSync(folder, { withFileTypes: true })) {
if (entry.name === 'node_modules' || entry.isSymbolicLink())
continue
const file = path.join(folder, entry.name)
if (entry.isDirectory())
walk(file)
else if (entry.isFile() && /\.(?:node|wasm|exe)$|(?:worker|worklet)[^/\\]*\.[cm]?js$/i.test(entry.name))
resources.push({ package: `${pkg.name}@${pkg.version}`, path: path.relative(directory, file).replaceAll('\\', '/'), bytes: fs.statSync(file).size, sha256: hash(fs.readFileSync(file)) })
}
}
walk(directory)
}
return { node: process.versions.node, root, packages: [...packages.values()].sort((a, b) => a.path.localeCompare(b.path)), requests, missing, resources, rootTools: requests.filter(edge => edge.from === '.' && edge.field === 'devDependencies') }
}
if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
assert.equal(process.argv.length, 4, 'Use <checkout> <report.json>')
const graph = installedGraph(process.argv[2])
fs.writeFileSync(process.argv[3], `${JSON.stringify(graph, null, 2)}\n`)
console.log(`Installed graph: ${graph.packages.length} package instances, ${graph.requests.length} resolved edges, ${graph.resources.length} native/worker/WASM resources; ${graph.missing.length} absent optional/peer/required requests retained`)
}
+47
View File
@@ -0,0 +1,47 @@
import assert from 'node:assert/strict'
import fs from 'node:fs'
import path from 'node:path'
// eslint-disable-next-line test/no-import-node-test -- Verify install comparison cannot borrow ancestor dependencies.
import test from 'node:test'
import { consumerDirectory, removeConsumer } from '../../scripts/package-consumer.mjs'
import { installedGraph } from './install-graph.mjs'
function write(directory, manifest) {
fs.mkdirSync(directory, { recursive: true })
fs.writeFileSync(path.join(directory, 'package.json'), JSON.stringify(manifest))
}
test('installation audit rejects a dependency resolved from the parent checkout', () => {
const directory = consumerDirectory()
try {
const root = path.join(directory, 'checkout')
write(root, { name: 'root', version: '1.0.0', dependencies: { borrowed: '1.0.0' } })
fs.mkdirSync(path.join(root, 'packages'))
write(path.join(directory, 'node_modules/borrowed'), { name: 'borrowed', version: '1.0.0' })
assert.throws(() => installedGraph(root), /escaped isolated checkout/)
}
finally { removeConsumer(directory) }
})
test('installation audit records workspace peers, nested resolution and resource identities', () => {
const directory = consumerDirectory()
try {
write(directory, { name: 'root', version: '1.0.0', devDependencies: { compiler: '1.0.0' }, dependencies: { buffer: '5.7.1' } })
write(path.join(directory, 'node_modules/buffer'), { name: 'buffer', version: '5.7.1' })
write(path.join(directory, 'node_modules/compiler'), { name: 'compiler', version: '1.0.0', dependencies: { engine: '2.0.0' }, scripts: { postinstall: 'node install.js' } })
write(path.join(directory, 'node_modules/compiler/node_modules/engine'), { name: 'engine', version: '2.0.0' })
fs.writeFileSync(path.join(directory, 'node_modules/compiler/node_modules/engine/code.wasm'), 'fixture')
write(path.join(directory, 'packages/plugin'), { name: 'plugin', version: '1.0.0', peerDependencies: { host: '^1' }, peerDependenciesMeta: { host: { optional: true } } })
const graph = installedGraph(directory)
assert.equal(graph.rootTools[0].resolved, 'compiler@1.0.0')
assert.equal(graph.requests.find(item => item.name === 'buffer').resolved, 'buffer@5.7.1')
assert.equal(graph.requests.find(item => item.name === 'engine').resolved, 'engine@2.0.0')
assert.equal(graph.missing[0].name, 'host')
assert.equal(graph.missing[0].optional, true)
assert.equal(graph.resources.length, 1)
assert.equal(graph.resources[0].package, 'engine@2.0.0')
assert.equal(graph.resources[0].bytes, 7)
assert.equal(graph.packages.find(item => item.name === 'compiler').lifecycle.postinstall, 'node install.js')
}
finally { removeConsumer(directory) }
})
+6 -2
View File
@@ -4544,11 +4544,15 @@
"dependsOn": [
"ENG-09"
],
"status": "todo",
"status": "done",
"risk": "H",
"deliverable": "独立目录的 Bun 安装与 Yarn 冻结基线对比,默认仍为用户选定的 Yarn",
"acceptance": "Node 测试仍通过;安装与资源一致才决定采用;不改 bundler;不自动替换默认 packageManager 或维护锁文件",
"evidence": []
"evidence": [
"changes/2026-09-15-MOD-01-bun-evaluation.md",
"baselines/bun-install-validation.json",
"bun-evaluation.md"
]
},
{
"id": "MOD-PLUGIN-01",
+4
View File
@@ -32,6 +32,10 @@ TypeScript 用于自有源码、检查与声明生成;Vite/Rollup 保留现有
## Bun 试点
MOD-01 已完成固定版本隔离评估,结论为保留 Yarn。Bun 1.4.2 普通迁移后 Node
测试/类型/完整构建通过,但冻结迁移、传递依赖和钩子策略不等价;详见
[实测结论与复跑](bun-evaluation.md)。以下是评估标准,不表示已采用 Bun。
| 步骤 | 检查 |
| --- | --- |
| 依赖基线 | 保存当前依赖和工作区行为,明确唯一锁文件与版本 pin;初次固定锁不夹带大规模升级 |