构建工具

0 阅读19分钟

一、技术难点

前 34 篇一直在浏览器里量东西:渲染管线、加载、网络、SSR、运行时、bundle、图片字体、Worker、WASM、性能监控。这一篇要回到所有那一切的上游——产物是怎么被造出来的。

构建工具是前端工程里最"黑盒"的一层。它每天被跑几百次,但绝大多数人对它的认知停留在三个词:vite build、tree-shaking、hash 变了要重传。一旦构建慢了、产物诡异了、缓存莫名失效了,就只能靠猜。

所以我不用任何第三方依赖,从零写了一个迷你打包器:scan.mjs(词法器 + 模块语法扫描)、build.mjs(模块图 + tree-shaking + 作用域提升链接 + 分块 + 内容哈希),配 test-bundler.mjs(33 条断言全绿,其中 5 组是同真实磁盘上的原生 ESM 做差分对照)和 bench.mjs(13 组实测)。环境:Node 22.23.3 / darwin-arm64 / V8 12.4。

先把结论摆出来。下面每一条都是这台机器上跑出来的,不是推演:

流传的说法实测结果差在哪
「扫描依赖就是正则找 import」3 个伪装样本(多行模板 / 块注释 / 正则字面量里各藏一条假 import)3/3 全部误报成真边;同时它比真词法器快 43.7x(0.35ms vs 15.22ms / 500 模块)快是真的,错也是真的。错误依赖边 = 多打包或漏打包,且只在运行时炸
「tree-shaking 就是删没用的 export」同一份 300 模块源码,按入口引用比例 10%/30%/60%/100%,产物降幅 96.5% / 88.8% / 78.2% / 64.6%降幅几乎完全由"有多少模块根本没进图"决定(270/301 未进图),不是由摇树算法多聪明决定
「拆细一点缓存命中率高」改一行活代码:单块失效 100%;shared 两块 61.1%;per-module 141 块只失效 89B = 0.4%但 per-module 是 141 个请求。体积换请求数,没有免费午餐
「chunk hash 把依赖也算进去更安全」改了 1 个叶子模块:只哈希自身失效 89B (0.4%);链式哈希失效 18.6KB (91.8%),波及 124 个块爆炸半径放大 214.40x。安全的是"绝不拿到旧产物",不是"缓存能省"
「构建慢就换语言重写」全链路 114.9ms 里:建图(含解析) 58.5%、摇树 14.8%、链接 18.2%、哈希 8.4%;其中纯词法切分就占 31.5%先量。我的链接阶段原本是 O(语句×token),改单遍后 65.9ms → 21.1ms(3.13x),比换语言便宜得多
「自己写哈希比 sha256 快」BigInt 版 FNV-1a 16.26 MB/s;Math.imul 双通道版 344.38 MB/s;原生 sha256 478.39 MB/s手写 BigInt 哈希比原生 sha256 慢 29.4x。"JS 一定比 C 慢"在这里是错的

1.1 难点一:扫描不是"找字符串",是"避免把字符串当真边"

真正难的不是找到 import,而是在找到它之前知道自己处在什么语境里。同一段文本,import 四个字母出现在字符串、注释、正则字面量、模板插值里时,语义完全不同:

const tricky = [
  'const t = `\nimport x from "./fake-tpl.mjs"\n`;',
  '/*\nimport y from "./fake-block.mjs"\n*/',
  'const re = /\nimport z from "\\/fake-re.mjs"\n/;',
];

更隐蔽的是 / 的歧义:if (a) /re/.test(b) 里是正则,(a + b) / 2 / c 里是除法,两者前面的 token 序列长度一样。工业界的解法是维护一个"控制语句括号栈":if/while/for/with/switch/catch 后面的 ( 被标记为 ctrl,与之配对的 ) 后面允许正则;普通 ( 配对出来的 ) 后面是除法。这一个细节就是 30 行代码,漏掉它,词法器会从某一处开始把整个文件切错。

1.2 难点二:改名不是字符串替换,是"作用域 + 语境"

打包器做作用域提升(scope hoisting)时要把每个模块的顶层绑定改名成全局唯一名(Rollup 就是这么干的)。词法级改名看起来能糊过去,但有两个必踩的坑:

坑 A:模板插值。 `${name}` 里 name 是引用,必须改;但它藏在 tpl token 的嵌套 parts 里,扁平遍历 token 流根本看不见。实测:400 个模块中 197 个用了模板插值,漏递归直接让整个产物抛 ReferenceError(0/400 通过)。一个 40 行函数级别的疏漏,在 197 个模块上同时爆发,而且只在运行时暴露——构建是全绿的。

坑 B:改名位置判定。 同一个标识符 token,出现在 obj.name(属性,不能改)、{ name: 1 }(键,不能改)、{ name }(简写属性,必须改成 name: mN$name)、return name(引用,要改)时,处置完全不同。

1.3 难点三:tree-shaking 的正确性边界

真正难的是"什么时候不能删":

  • 顶层副作用:模块里有一句 globalThis.x = 1,整块就不能丢。
  • import 语句假活:如果让 import 语句参与"存活语句"集合,那么入口里所有 import {a, b, c} 都会把 a/b/c 判成活的——我第一版就是这样,产物里 3 个只有 1 个真正被用到的导出全部留着。
  • 默认导出的名字口径:export default function fmt(){} 的"导出名"是 default,"本地名"是 fmt。用导出名做存活分析,具名默认导出永远摇不掉。
  • 复导出边:export { x } from './y.mjs' 里的 ./y.mjs 也是一条依赖边。漏掉它,./y.mjs 根本不进图,产物里引用一个不存在的名字。

1.4 难点四:分块、哈希与"爆炸半径"

缓存不是"有没有 hash"的问题,是一次改动会让多少字节失效的问题。而这取决于两个彼此独立的选择:分块粒度(§2 表里的 100% → 0.4%)和哈希是否链上依赖(0.4% → 91.8%)。很多人只调前者,然后在"为什么改了 utils 全站缓存都废"里出不来——那是后者的锅。

1.5 难点五:确定性

同一个仓库、同一份源码,两次构建必须产出逐字节相同的结果(否则 CDN 上会出现内容相同、文件名不同的产物,缓存命中率归零)。这件事靠"小心"是保证不了的,必须靠固定的遍历序:本实现用"依赖先于依赖者"的后序 DFS(topoOrder)+ Tarjan 环检测。反例实测很直白:改成按文件枚举顺序拼接,打乱 5 次得到 5 种不同哈希。

1.6 难点六:性能,以及"你以为的瓶颈"

bench.mjs 的 §9 把 3000 模块冷启动拆开:建图(含解析)67.3ms / 摇树 17.0ms / 链接 20.9ms / 哈希 9.71ms,合计 114.9ms,其中纯词法切分 36.2ms = 31.5%。这个分布说明两件事:第一,词法/解析是整条管道的下限,想有量级提升只能换 parser(esbuild/SWC 用 Go/Rust 换的就是这 31.5%);第二,链接阶段我原本写了 O(语句数 × token 数)——对每条语句都线性扫一遍全部 token 来标记删除区间。3000 模块下它是 65.9ms,改成"token 单遍扫描 + 语句指针推进"后 21.1ms,快 3.13x 且输出逐字节相同。

这个 3.13x 比任何"上一个 Rust 重写"都便宜。先量,再换。


二、完整解法

2.1 词法器:正则歧义 + 模板递归

核心是两个状态:parens 栈(判断 ) 后面能不能是正则)和 parts(模板插值的嵌套 token)。

const CONTROL_KW = new Set(['if', 'while', 'for', 'with', 'switch', 'catch']);
const REGEX_PREV_KW = new Set(['return', 'typeof', 'instanceof', 'in', 'of', 'new', 'delete', 'void', 'throw', 'case', 'do', 'else', 'yield', 'await']);

function regexAllowed(prev) {
  if (!prev) return true;                                   // 文件开头
  if (prev.t === 'num' || prev.t === 'str' || prev.t === 'tpl' || prev.t === 're') return false;
  if (prev.t === 'id') return REGEX_PREV_KW.has(prev.v);
  if (prev.t === 'punc') {
    if (prev.v === ')') return prev.ctrl === true;           // if(...) 的 ) → 允许正则
    if (prev.v === ']') return false;
    if (prev.v === '++' || prev.v === '--') return false;
    return true;
  }
  return true;
}

ctrl 标记在遇到 ( 时记录"前一个 token 是不是控制关键字",遇到 ) 时出栈带出来。模板字符串则把 ${...} 里的表达式递归词法化,存成 parts,并记下绝对偏移 es/ee——后面改名要靠它还原位置。

实测性能:500 模块 / 196.3KB,tokenize 13.28ms(14.43 MB/s),scanModule 15.22ms(12.59 MB/s)。这个 14 MB/s 偏低,原因很具体——见 §2.6。

2.2 模块语法扫描:语句区间是后面一切的基础

扫描器不只回答"导入了什么",还要给出每条语句的字符区间(statements),因为 tree-shaking 要按语句删、链接要按语句删包装:

const stmtEnd = (k, groupAt = -1) => {
  let d = 0;
  let openedBlock = false;
  for (let m = k; m < toks.length; m++) {
    const t = toks[m];
    if (t.t !== 'punc') continue;
    if (t.v === '(' || t.v === '[') d++;
    else if (t.v === ')' || t.v === ']') { d--; if (d < 0) return m; }
    else if (t.v === '{') { if (m !== groupAt && d === 0) openedBlock = true; d++; }
    else if (t.v === '}') {
      d--;
      if (d < 0) return m;
      if (d === 0 && openedBlock) return m;
    } else if (t.v === ';' && d === 0) return m;
  }
  return toks.length - 1;
};

这段 18 行的函数踩过两个真实的坑,都值得写进任何构建器的 code review checklist:

坑 1:export function f(){} 没有分号。 只找 ';' 的话,stmtEnd 会一路吞掉后面几十行,直到遇到下一个分号。测试里那条 无分号的 export function 不会吞掉后续语句 就是在守这个。

坑 2:说明符列表的 {} 不是语句块。 import { a } from './x' 里的 { a } 在括号深度上和函数体一模一样。所以 stmtEnd 加了 groupAt 参数:调用方把说明符 { 的下标传进来,那对花括号不参与"语句块"判定。没这个参数,import 语句会在 } 处提前结束,from './x' 被当作一条顶层语句——进而被判成副作用。

2.3 模块图:Tarjan 找环 + 后序定序

export function topoOrder(modules, entryId) {
  const out = [];
  const state = new Map();           // 1=在栈 2=完成
  const walk = (id) => {
    if (state.get(id) === 2) return;
    if (state.get(id) === 1) return; // 环:跳过,不死循环
    state.set(id, 1);
    for (const d of modules[id].deps) walk(d.targetId);
    state.set(id, 2);
    out.push(id);
  };
  walk(entryId);
  return out;
}

环用 Tarjan 单独识别(返回所有长度 > 1 的强连通分量 + 自环)。执行顺序用后序 DFS 保证"依赖先于依赖者",环检测用 Tarjan 保证不漏——两件事分开做,因为它们的正确条件不同。

还有一个容易被忽略的点:export { x } from './y.mjs' 也是依赖边。建图时必须把 scan.exports 里带 source 的项也 visit 一遍,否则被复导出的模块压根不进图,byPath.get(path) 直接拿到 undefined。

实测建图:200 / 1000 / 3000 模块 = 3.4ms / 16.8ms / 52.3ms,单模块摊薄 0.017 / 0.017 / 0.017ms,近似线性。

顺带一个反直觉的收获:3000 模块的项目,磁盘上 3001 个文件,只有 1201 个进了图、被解析。入口不引用的模块连词法分析都不做——这才是"项目很大但构建还行"的真正原因,也让"源码总量"这个指标基本没有参考价值。

2.4 tree-shaking:两级存活分析

模块内做语句级存活(导出声明 + 顶层声明的可达性传播),模块间做模块级存活(没被任何存活语句引用、且无副作用 → 整块丢弃)。

const markUsedExport = (modId, name) => {
  const cur = usedExports.get(modId);
  if (cur === '*') return false;
  const set = cur ?? new Set();
  if (set.has(name)) return false;
  set.add(name);
  usedExports.set(modId, set);
  return true;
};

口径统一是关键:usedExports 里存的永远是本地绑定名(local),不是导出名(exported)。export default function fmt(){} 记 fmt,export { hidden as viaList } 记 hidden。这条规则让我修掉了"具名默认导出永远摇不掉"的 bug。

引用传播用 fixpoint:语句活着 → 它引用到的本模块声明也活着 → 递归。import 语句不参与这个集合(它们不产出代码),只按"存活语句真正引用到的本地名"决定是否需要向目标模块要那个导出。

sideEffects 判定的实测代价(§11):同一份 100 模块项目,全部干净时产物 4.0KB;给每个模块加一条顶层副作用语句后 10.2KB,放大 2.55x。这就是 package.json 里 "sideEffects": false 的真实价值——也是它配错会把有副作用的模块摇掉、造成线上故障的原因。

2.5 链接:作用域提升 + 改名(含模板递归)

改名表按模块建立:本地声明 → m{id}${name},导入的本地名 → 目标模块对应绑定的全局名。渲染时走单遍扫描:

const rewriteToken = (t, toks, k, renames) => {
  if (t.t !== 'id') return t.v;
  const prevV = toks[k - 1] ? toks[k - 1].v : undefined;
  const nextV = toks[k + 1] ? toks[k + 1].v : undefined;
  if (prevV === '.' || prevV === '?.') return t.v;                      // 属性访问
  if (nextV === ':' && (prevV === '{' || prevV === ',')) return t.v;     // 对象键
  const target = renames.get(t.v);
  if (!target) return t.v;
  const prevIsOpen = prevV === '{' || prevV === ',';
  const nextIsClose = nextV === '}' || nextV === ',';
  if (prevIsOpen && nextIsClose) return `${t.v}: ${target}`;             // 简写属性
  return target;
};

模板 token 则整段交给递归改名器,它按 parts 里的绝对偏移把每段 ${...} 切出来重新处理:

export function renameSnippet(text, renames) {
  if (!renames.size) return text;
  const toks = tokenizeSnippet(text);
  let out = '';
  let prevEnd = 0;
  for (let k = 0; k < toks.length; k++) {
    const t = toks[k];
    out += text.slice(prevEnd, t.s);
    if (t.t === 'tpl' && t.parts) {
      let rebuilt = '';
      let last = t.s;
      for (const part of t.parts) {
        if (part.expr) {
          rebuilt += text.slice(last, part.es) + renameSnippet(text.slice(part.es, part.ee), renames);
          last = part.ee;
        }
      }
      rebuilt += text.slice(last, t.e);
      out += rebuilt;
    } else {
      out += rewriteToken(t, toks, k, renames);
    }
    prevEnd = t.e;
  }
  out += text.slice(prevEnd);
  return out;
}

命名空间导入(import * as ns)会生成一个对象字面量把目标模块的导出聚起来。这里有个我自己踩的坑:这个 shim 必须挂成"导入方模块的 prelude",不能只拼在最终 code 字符串头部——否则分了块之后,chunkCode() 从 pieces 重新拼装时它凭空消失。单块构建看起来完全正常,一上多块就 Cannot read properties of undefined。

2.6 性能:三处实测出来的优化

① 字符分类用 charCode,不用正则。 词法器只有 14 MB/s,热路径就在这里:

const isIdPartCode = (c) => {
  const x = c.charCodeAt(0);
  return (x >= 48 && x <= 57) || (x >= 65 && x <= 90) || (x >= 97 && x <= 122) || x === 95 || x === 36;
};

200 万次判断:正则 .test() 39.3ms,charCode 比较 8.0ms,快 4.9x。每个字符都新建一次正则匹配上下文——这就是那 14 MB/s 的来历。

② 链接别写成双循环。 见 §1.6:65.9ms → 21.1ms(3.13x),输出逐字节相同。测试里我用 lF.code === lN.code 守住这个等价性——没有等价性断言的优化就是重写。

③ 哈希别再手搓 BigInt。 三种实现(200KB / 200 块):

实现耗时吞吐
FNV-1a 64(BigInt 逐字符)12.04ms16.26 MB/s
FNV-1a ×2 32(Math.imul 双通道)0.57ms344.38 MB/s
sha256(原生 OpenSSL)0.41ms478.39 MB/s

Math.imul 版比 BigInt 版快 21.2x,但原生 sha256 仍比它快 1.4x,比 BigInt 版快 29.4x。生产里选 xxhash 从来不是为了"比 sha256 快",而是为了跨语言一致、增量哈希和抗碰撞;如果你在 JS 里手写哈希,"自己写的更快"基本是错觉。

2.7 测试:33 条断言 + 5 组真实 ESM 差分

单元断言能守住边界,但守不住语义。所以关键手段是差分对照:把同一份夹具真实写进临时目录,用原生 import() 跑一遍,再用打包产物跑一遍,逐个导出比对。

const native = await import(pathToFileURL(path.join(dir, 'src/main.mjs')).href);
const bundled = new Function('__ex', r.chunks[0].code + '\nreturn __ex;')({});
for (const k of Object.keys(native)) {
  const nv = native[k], bv = bundled[k];
  if (typeof nv === 'function') {
    assert.equal(String(bv()), String(nv()), `导出 ${k}() 结果不一致`);
    continue;
  }
  assert.deepEqual(bv, nv, `导出 ${k} 与原生不一致`);
}

5 组差分覆盖:常规项目(影子变量 / 解构 / 模板 / 命名空间 / 副作用)、复导出链(export {} from + export * 穿透两层)、副作用语句顺序、120 模块合成项目、默认导出四种形态(表达式 / 具名类 / 具名函数 / 列表改名)。

这 5 组差分抓出了 4 个我自己写出来的 bug:模板插值里的引用看不见、具名默认导出摇不掉、复导出边没进图、re-export 目标模块没被纳入。每一个都让构建全绿、产物出错。

其余断言里最值钱的三条:

// 1) 确定性:打乱文件枚举顺序,产物哈希必须不变
assert.equal(a.chunks[0].hash, b.chunks[0].hash, '输出不确定 → CI 里缓存会一直失效');

// 2) 分块粒度:改一个模块,per-module 只失效一块
assert.equal(changed, 1, `应只失效 1 块,实际 ${changed}`);

// 3) 无分号的 export function 不能吞掉后续语句
assert.ok(scanModule("export function f(){}\nexport const K = 1;").exports.some((e) => e.exported === 'K'));

三、应用场景

3.1 先定位"慢在哪一档",再谈选型

用本篇的全链路拆分当尺子,把常见现象对上号:

现象对应阶段该动的地方
冷启动慢、热更新慢但产物正常建图(含解析)58.5%,词法 31.5%换 parser(esbuild/SWC 做 transform,Vite 默认就是这个组合);或做扫描缓存
改一行要等好几秒链接/打包粒度走 dev 不打包(Vite 的 ESM dev server);或 chunk 级增量(Turbopack)
构建不慢,但每次改完用户还是要重下几 MB分块 + 哈希传播调 chunk 策略、把 vendor 拆稳定的部分、检查哈希有没有把依赖链进来
产物比预期大很多tree-shaking 边界查 sideEffects 配置、查有没有顶层副作用语句、查 export default 口径
昨天的产物和今天一模一样但 hash 变了确定性查遍历序有没有依赖 readdir/Object.keys/时间戳

3.2 「拆多细」是一道有唯一正确答案的题

§4 的四个策略在同一次一行改动下的表现:

策略块数总字节需重下比例
single120.4KB20.4KB100.0%
vendor120.4KB20.4KB100.0%
shared220.4KB12.5KB61.1%
per-module14120.3KB89B0.4%

注意 vendor 在这里退化成 1 块(合成项目没有真实 node_modules),这恰好说明策略必须匹配项目结构。而 per-module 的 0.4% 不是"免费"——它是 141 个 HTTP 请求,在 HTTP/1.1 下会被队头阻塞吃光,在 HTTP/2 下也有服务器并发流上限。

工程答案: 用"引用计数"分层(shared 那一路),把被 ≥2 个入口引用、且变更频率低的模块放进长期缓存块;把高频变更的业务代码按路由切。判据不是"越小越好",是变更频率 × 共享度的二维聚类。

3.3 哈希链:安全性与缓存命中率的正面冲突

§5 的数字值得单独记:一次叶子改动,只哈希自身失效 89B(0.4%),链式哈希失效 18.6KB(91.8%),波及 124 个块,爆炸半径放大 214.40x。

链式哈希(把依赖的 hash 也算进本块 hash)买的是"依赖变了不可能拿到旧产物"这个硬保证,代价是缓存命中率。所以:

  • 产物之间的依赖(A chunk 引用了 B chunk 的导出的名字/序号)→ 必须链,否则运行时对不上。
  • 只是"逻辑上相关"(A 和 B 都 import 了 utils)→ 不该链。

分清这两者,是"改了 utils 全站缓存全废"和"上线白屏资源 404"之间唯一的分界线。

3.4 什么时候真的该自己写构建器

不是"造轮子好玩",是三种具体的场景:

  1. 库作者:你要产出的是 esm + cjs + .d.ts 三件套,需要精确控制 external、preserveModules、sideEffects——这些行为必须能从源码层面解释,不能靠试。
  2. 私有语法 / DSL:公司内部的 definePage()、自研的模板指令需要 transform 插件,而插件写对的前提是知道扫描器怎么切 token、怎么判副作用。
  3. 构建卡点必须自己定位:CI 上 8 分钟构建,厂商只会说"升级版本"。本篇这套拆分(建图/摇树/链接/哈希 + 词法占比)可以在一小时内指出是哪一档超了预算。

3.5 与前后篇的咬合

  • Day25 渲染管线 / Day29 运行时性能 / Day34 性能监控:本篇的产物直接决定前三篇量的那些指标。性能预算的卡点就应该落在这里——构建时把首屏 chunk 字节卡在阈值上,比上线后看 LCP 便宜两个数量级。
  • Day26 加载性能 / Day30 bundle 优化:Day30 讲的拆包/预加载/manifest,其上游就是本篇的 planChunks 与 contentHash。分块策略选错,Day30 的一切优化都白做。
  • Day28 SSR/SSG/ISR:SSR 的产物也要经过构建,且"服务端 chunk / 客户端 chunk"是两套图,本篇的确定性要求对它是硬约束(否则每次 build 都产生新文件)。
  • Day33 WASM:WASM 资源怎么进 chunk、怎么算 hash、怎么做流式编译,落在本篇的分块与哈希里。
  • Day36 Monorepo / Day38 流水线:增量构建与远程缓存的地基就是本篇 §10——扫描缓存能省掉解析(198ms → 131ms,1.5x),但省不掉图遍历;要再往上就得做 chunk 级增量。
  • Day40 可观测性:构建埋点(各阶段耗时、缓存命中率、产物体积 diff)应该和运行时的 correlation id 打通,才能回答"这次变慢是代码变了还是构建变了"。

四、总结

第一,构建工具的核心不是"打包",是"精确的语境判断"。 找 import 是 5 分钟的事,判断这个 import 是真代码还是注释里的字面量,是 500 行的事。三个伪装样本 3/3 误报,而它比真词法器快 43.7x——快的部分正好是不需要的部分。

第二,tree-shaking 的收益主要来自"没进图",不来自"删得巧"。 96.5% 的降幅里有 270/301 个模块从未被解析过。真正需要算法的地方是那些反例:顶层副作用、具名默认导出、复导出边。(2.55x 的副作用代价就是最好的注脚。)

第三,缓存是两个独立旋钮,别只拧一个。 分块粒度管 100% → 0.4%,哈希链管子 0.4% → 91.8%(214.40x)。前者是体积与请求数的权衡,后者是安全性与命中率的权衡。混为一谈,就会在"缓存没生效"和"上线 404"之间反复横跳。

第四,确定性是构建的硬指标,而且是可以用断言守住的。 打乱枚举顺序后 5 种不同哈希——这种 bug 不会让构建失败,只会让 CDN 缓存静默失效。一条 assert.equal(hash1, hash2) 就永久解决。

第五,性能优化要先量,而且量之前先对齐口径。 我第一次测"词法占全链路多少"时得出了"建图比纯词法还快"的荒谬结论——因为词法基准跑了全部 3001 个文件,而建图只解析可达的 1201 个。口径不对齐,所有优化决策都是错的。 对齐之后真相很清楚:词法 31.5%、链接 18.2%(原本 46%,一个双循环把它抬上去的)。

最后一句: 33 条断言 + 5 组真实 ESM 差分,抓出了 4 个"构建全绿、产物出错"的 bug——漏递归模板插值、具名默认导出摇不掉、复导出边没进图、命名空间 shim 在分块时消失。构建工具的质量不体现在它能打包,体现在它拒绝打包错的东西。 下一篇 Day36 进 Monorepo,那个话题的每一个痛点(依赖提升、幽灵依赖、增量构建、跨包引用)都会回到今天这张模块图上。


全部代码与实测脚本:day35-assets/(scan.mjs / build.mjs / fixtures.mjs / test-bundler.mjs / bench.mjs / bench-log.txt)。 复现:node test-bundler.mjs(33 条断言,exit 0)、node bench.mjs(13 组实测,全部数字现场跑出)。


附录:完整代码

以下脚本均来自 day35-assets/(完整源码)。 运行:node test-bundler.mjs 验证构建器语义;node bench.mjs 跑基准;node extract-and-test.mjs 提取正文代码块逐段自检。

build.mjs —— 手写构建器内核(依赖图、转换、打包、chunk 切分,837 行)

// build.mjs —— 零依赖迷你打包器:模块图 → tree-shaking → 作用域提升链接 → 分块 → 内容哈希
import { scanModule, hasSideEffects, tokenize as tokenizeSnippet } from './scan.mjs';

const EXTS = ['.mjs', '.js'];
let uid = 0;

/* ============ 1. 虚拟文件系统与解析 ============ */

export function createFS(files) {
  // files: Map<path, source> 或普通对象;一律转成 Map,路径规范成不带 ./ 的形式
  const map = new Map();
  const src = files instanceof Map ? [...files.entries()] : Object.entries(files);
  for (const [k, v] of src) map.set(norm(k), v);
  return {
    get: (p) => map.get(norm(p)),
    has: (p) => map.has(norm(p)),
    keys: () => [...map.keys()],
    size: () => map.size,
    set: (p, v) => map.set(norm(p), v),
    clone: () => createFS(new Map(map)),
    raw: map,
  };
}

function norm(p) {
  const parts = p.replace(/\\/g, '/').split('/');
  const out = [];
  for (const seg of parts) {
    if (seg === '' || seg === '.') continue;
    if (seg === '..') { out.pop(); continue; }
    out.push(seg);
  }
  return out.join('/');
}

function dirname(p) {
  const i = p.lastIndexOf('/');
  return i === -1 ? '' : p.slice(0, i);
}

/**
 * 解析模块说明符。真实构建器这里是 npm 解析算法(exports 字段/条件导出/自引用…),
 * 这里只实现相对路径 + 扩展名探测 + 目录 index,够覆盖 ESM 语义差异。
 */
export function resolveSpec(fs, fromPath, spec) {
  if (!spec.startsWith('.')) {
    // 裸模块:当作 node_modules 下的路径
    const base = `node_modules/${spec}`;
    for (const cand of probe(fs, base)) return cand;
    return null;
  }
  const base = norm(`${dirname(fromPath)}/${spec}`);
  for (const cand of probe(fs, base)) return cand;
  return null;
}

function* probe(fs, base) {
  if (fs.has(base) && !isDirish(fs, base)) { yield base; return; }
  for (const e of EXTS) {
    if (fs.has(base + e)) { yield base + e; return; }
  }
  for (const e of EXTS) {
    const idx = `${base}/index${e}`;
    if (fs.has(idx)) { yield idx; return; }
  }
}

// 虚拟 FS 里用 "a/" 结尾代表目录占位,直接忽略
const isDirish = (fs, p) => p.endsWith('/');

/* ============ 2. 模块图 ============ */

export function buildGraph(fs, entryPath, opts = {}) {
  fs = fs.raw ? fs : createFS(fs);
  const entry = norm(entryPath);
  const byPath = new Map();
  const modules = [];
  const unresolved = [];
  // 扫描缓存:键=路径,值={source, scan}。源码按字符串比较,
  // 命中就直接复用上次的词法/语法结果 —— 这就是"增量构建"能成立的全部原因。
  const cache = opts.cache ?? null;
  let cacheHit = 0, cacheMiss = 0;

  const addModule = (path) => {
    const source = fs.get(path);
    if (source === undefined) throw new Error(`模块不存在: ${path}`);
    let scan;
    const cached = cache ? cache.get(path) : undefined;
    if (cached && cached.source === source) { scan = cached.scan; cacheHit++; }
    else { scan = scanModule(source); cacheMiss++; if (cache) cache.set(path, { source, scan }); }
    const mod = {
      id: modules.length,
      path,
      source,
      scan,
      bytes: source.length,
      // 静态依赖(会参与 tree-shaking 与执行顺序)
      deps: [],
      // 动态 import(单独成块,不参与 tree-shaking)
      dynamicDeps: [],
      sideEffects: hasSideEffects(scan),
    };
    modules.push(mod);
    byPath.set(path, mod);
    return mod;
  };

  const visit = (path) => {
    if (byPath.has(path)) return byPath.get(path);
    const mod = addModule(path);
    for (const imp of mod.scan.imports) {
      const target = resolveSpec(fs, path, imp.source);
      if (!target) { unresolved.push({ from: path, spec: imp.source }); continue; }
      mod.deps.push({ spec: imp.source, targetPath: target });
    }
    // `export {x} from './y'` / `export * from './y'` 同样是依赖边,
    // 漏掉它们会导致被复导出的模块根本没进图(踩过:byPath.get() 拿到 undefined)
    for (const ex of mod.scan.exports) {
      if (!ex.source) continue;
      const target = resolveSpec(fs, path, ex.source);
      if (!target) { unresolved.push({ from: path, spec: ex.source }); continue; }
      if (!mod.deps.some((d) => d.targetPath === target)) mod.deps.push({ spec: ex.source, targetPath: target });
    }
    for (const spec of mod.scan.dynamicImports) {
      if (spec == null) { mod.dynamicDeps.push({ spec: null, targetPath: null }); continue; }
      const target = resolveSpec(fs, path, spec);
      if (!target) { unresolved.push({ from: path, spec }); continue; }
      mod.dynamicDeps.push({ spec, targetPath: target });
    }
    if (mod.sideEffects) mod.sideEffectDeps = mod.deps.length; // 仅作记录
    for (const d of mod.deps) visit(d.targetPath);
    for (const d of mod.dynamicDeps) if (d.targetPath) visit(d.targetPath);
    return mod;
  };

  const entryMod = visit(entry);
  // 回填 targetId(因为 visit 期间 id 可能尚未分配)
  for (const mod of modules) {
    for (const d of mod.deps) d.targetId = byPath.get(d.targetPath).id;
    for (const d of mod.dynamicDeps) d.targetId = d.targetPath ? byPath.get(d.targetPath).id : null;
  }

  const cycles = findCycles(modules);
  const order = topoOrder(modules, entryMod.id);
  return {
    fs, entry, entryId: entryMod.id, modules, byPath, order, cycles, unresolved,
    scanStats: { cacheHit, cacheMiss },
  };
}

/** Tarjan 强连通分量:返回所有长度>1 的环(含自环) */
export function findCycles(modules) {
  const index = new Map();
  const low = new Map();
  const onStack = new Set();
  const stack = [];
  const comps = [];
  let counter = 0;

  const strong = (v) => {
    index.set(v, counter); low.set(v, counter); counter++;
    stack.push(v); onStack.add(v);
    for (const d of modules[v].deps) {
      const w = d.targetId;
      if (!index.has(w)) { strong(w); low.set(v, Math.min(low.get(v), low.get(w))); }
      else if (onStack.has(w)) low.set(v, Math.min(low.get(v), index.get(w)));
    }
    if (low.get(v) === index.get(v)) {
      const comp = [];
      let w;
      do { w = stack.pop(); onStack.delete(w); comp.push(w); } while (w !== v);
      comps.push(comp);
    }
  };
  for (const m of modules) if (!index.has(m.id)) strong(m.id);

  return comps.filter((c) => {
    if (c.length > 1) return true;
    // 自环
    const v = c[0];
    return modules[v].deps.some((d) => d.targetId === v);
  });
}

/** 后序 DFS:依赖先于依赖者(环内按发现顺序打破) */
export function topoOrder(modules, entryId) {
  const out = [];
  const state = new Map(); // 0=未访问 1=在栈 2=完成
  const walk = (id) => {
    if (state.get(id) === 2) return;
    if (state.get(id) === 1) return; // 环:直接跳过,避免死循环
    state.set(id, 1);
    for (const d of modules[id].deps) walk(d.targetId);
    state.set(id, 2);
    out.push(id);
  };
  walk(entryId);
  return out;
}

/* ============ 3. 导出解析(含 re-export 穿透) ============ */

/**
 * 把「模块 M 的导出名 name」解析成「某个模块的本地绑定」。
 * 需要穿透 export {x} from './y' 与 export * from './y',并防止环。
 */
export function resolveExport(graph, modId, name, seen = new Set()) {
  const key = `${modId}#${name}`;
  if (seen.has(key)) return null;
  seen.add(key);
  const mod = graph.modules[modId];

  for (const e of mod.scan.exports) {
    if (e.exported !== name) continue;
    if (e.source) {
      const target = resolveSpec(graph.fs, mod.path, e.source);
      if (!target) return null;
      const tid = graph.byPath.get(target).id;
      return resolveExport(graph, tid, e.imported, seen);
    }
    return { modId, local: e.local };
  }
  // export * 穿透
  for (const e of mod.scan.exports) {
    if (e.exported !== '*' || !e.source) continue;
    const target = resolveSpec(graph.fs, mod.path, e.source);
    if (!target) continue;
    const tid = graph.byPath.get(target).id;
    const r = resolveExport(graph, tid, name, seen);
    if (r) return r;
  }
  return null;
}

/** 模块对外暴露的全部导出名(含穿透来的) */
export function allExportNames(graph, modId, seen = new Set()) {
  if (seen.has(modId)) return [];
  seen.add(modId);
  const mod = graph.modules[modId];
  const names = [];
  for (const e of mod.scan.exports) {
    if (e.exported === '*') {
      const target = resolveSpec(graph.fs, mod.path, e.source);
      if (target) names.push(...allExportNames(graph, graph.byPath.get(target).id, seen));
    } else if (!names.includes(e.exported)) {
      names.push(e.exported);
    }
  }
  return names;
}

/* ============ 4. Tree-shaking ============ */

/**
 * 两件事:
 *  A. 语句级存活分析(模块内:导出声明 + 顶层声明的可达性传播)
 *  B. 模块级存活分析(没被任何存活语句引用的、且无副作用的模块整块丢弃)
 */
export function treeShake(graph) {
  const M = graph.modules;
  const stmtCount = (m) => m.scan.statements.length;

  // ---- 每个模块的语句元信息 ----
  for (const mod of M) {
    mod.stmtInfo = mod.scan.statements.map((st, i) => ({
      i, kind: st.kind, names: st.names, start: st.start, end: st.end,
    }));
    // 顶层非 import/export 的语句 = 潜在副作用语句(由 scan 的 topLevelStmts 粗判)
    mod.declStmt = new Map(); // name -> stmt index
    for (const si of mod.stmtInfo) {
      if (si.kind === 'export-decl' || si.kind === 'export-list' || si.kind === 'export-default') {
        for (const nm of si.names) mod.declStmt.set(nm, si.i);
      }
    }
  }

  const included = new Set();   // 整模块纳入
  const usedExports = new Map(); // modId -> Set<name> | '*'
  const keptStmts = new Map();   // modId -> Set<stmtIndex>

  const markUsedExport = (modId, name) => {
    const cur = usedExports.get(modId);
    if (cur === '*') return false;
    const set = cur ?? new Set();
    if (set.has(name)) return false;
    set.add(name);
    usedExports.set(modId, set);
    return true;
  };

  // 入口整个模块纳入
  included.add(graph.entryId);
  usedExports.set(graph.entryId, '*');

  let changed = true;
  const guard = 10000;
  let iter = 0;
  while (changed) {
    changed = false;
    if (++iter > guard) throw new Error('tree-shake 未收敛');

    for (const mod of M) {
      const whole = usedExports.get(mod.id) === '*';
      if (!included.has(mod.id)) continue;

      // ---- 模块内语句存活 ----
      const kept = new Set();
      // 有副作用的模块:所有语句都必须保留(顺序也保留)
      const keepAll = mod.sideEffects || whole;
      if (keepAll) {
        // import 语句本身不产出代码,不能当"存活语句"参与引用传播,
        // 否则 entry 里所有 import 的本地名都会假活(踩过)
        for (const si of mod.stmtInfo) { if (si.kind !== 'import') kept.add(si.i); }
      } else {
        const used = usedExports.get(mod.id) ?? new Set();
        for (const si of mod.stmtInfo) {
          if (si.kind === 'statement') { kept.add(si.i); continue; }
          if (si.kind === 'import') continue; // 单独处理
          const hit = si.names.some((n) => used.has(n));
          if (hit) kept.add(si.i);
        }
      }

      // ---- 引用传播:保持活着的语句引用到的本地声明也要活 ----
      // 用 token 区间取引用集合(scan 已给出语句区间)
      const localRefs = collectStmtRefs(mod);
      let grew = true;
      while (grew) {
        grew = false;
        for (const si of mod.stmtInfo) {
          if (!kept.has(si.i)) continue;
          for (const nm of localRefs.get(si.i) ?? []) {
            const declSi = mod.declStmt.get(nm);
            if (declSi !== undefined && !kept.has(declSi)) { kept.add(declSi); grew = true; }
          }
        }
      }
      const prev = keptStmts.get(mod.id);
      keptStmts.set(mod.id, kept);
      if (!prev || prev.size !== kept.size) changed = true;

      // ---- 模块级:被存活语句用到的 import 才算需要 ----
      const liveRefs = new Set();
      for (const si of mod.stmtInfo) {
        if (!kept.has(si.i)) continue;
        for (const nm of localRefs.get(si.i) ?? []) liveRefs.add(nm);
      }
      // 只按"存活语句真正引用到的本地名"决定 import 是否需要。
      // (有副作用的模块里所有语句都存活,liveRefs 自然覆盖,无需特判)
      const needAllImports = false;

      for (const imp of mod.scan.imports) {
        if (imp.sideEffectOnly) {
          const t = resolveSpec(graph.fs, mod.path, imp.source);
          if (!t) continue;
          const tid = graph.byPath.get(t).id;
          if (!included.has(tid)) { included.add(tid); changed = true; }
          continue;
        }
        for (const sp of imp.specifiers) {
          if (!needAllImports && !liveRefs.has(sp.local)) continue;
          const r = resolveExport(graph, mod.id, sp.imported === '*' ? '__ns__' : sp.imported);
          const t = resolveSpec(graph.fs, mod.path, imp.source);
          if (!t) continue;
          const tid = graph.byPath.get(t).id;
          if (!included.has(tid)) { included.add(tid); changed = true; }
          if (sp.imported === '*') {
            if (usedExports.get(tid) !== '*') { usedExports.set(tid, '*'); changed = true; }
          } else {
            const target = resolveExport(graph, tid, sp.imported);
            if (target) {
              if (!included.has(target.modId)) { included.add(target.modId); changed = true; }
              if (markUsedExport(target.modId, target.local)) changed = true;
            }
            void r;
          }
        }
      }
      // 复导出边:`export {x} from` / `export * from` 的目标必须纳入
      for (const ex of mod.scan.exports) {
        if (!ex.source) continue;
        const tt = resolveSpec(graph.fs, mod.path, ex.source);
        if (!tt) continue;
        const tid2 = graph.byPath.get(tt).id;
        if (!included.has(tid2)) { included.add(tid2); changed = true; }
      }
      // 动态导入:始终纳入(单独成块),不动摇 shake
      for (const d of mod.dynamicDeps) {
        if (d.targetId != null && !included.has(d.targetId)) { included.add(d.targetId); changed = true; }
      }
    }
  }

  // 统计
  const includedMods = M.filter((m) => included.has(m.id));
  const totalBytes = M.reduce((a, m) => a + m.bytes, 0);
  const keptBytes = includedMods.reduce((a, m) => a + moduleEmittedSize(m, keptStmts.get(m.id)), 0);
  return {
    included, usedExports, keptStmts,
    stats: {
      modulesTotal: M.length,
      modulesIncluded: includedMods.length,
      modulesDropped: M.length - includedMods.length,
      bytesTotal: totalBytes,
      bytesKept: keptBytes,
    },
  };
}

/** 估算某模块实际会输出的源码字节(按保留的语句累加,含模块壳开销为 0) */
function moduleEmittedSize(mod, kept) {
  if (!kept) return 0;
  let n = 0;
  const stmts = mod.scan.statements;
  for (const si of mod.stmtInfo) {
    if (!kept.has(si.i)) continue;
    n += si.end - si.start;
  }
  return n;
}

/** 每条语句引用到的标识符(排除属性名与对象键),用 token 区间切 */
function collectStmtRefs(mod) {
  if (mod._stmtRefs) return mod._stmtRefs;
  const map = new Map();
  // 模板插值是嵌套 token 数组:不展开就看不见 `${importedName}`
  const flatten = (list, base = 0, out = []) => {
    for (const tk of list) {
      if (tk.t === 'tpl' && tk.parts) {
        for (const pt of tk.parts) {
          // 插值里的 token 偏移是"相对于插值片段"的,必须加上片段起点还原成绝对偏移
          if (pt.expr) flatten(pt.expr, pt.es, out);
        }
        continue;
      }
      out.push(base === 0 ? tk : { ...tk, s: tk.s + base, e: tk.e + base });
    }
    return out;
  };
  const toks = flatten(mod.scan.toks);
  for (const si of mod.stmtInfo) {
    const refs = new Set();
    for (let k = 0; k < toks.length; k++) {
      const t = toks[k];
      if (t.t !== 'id') continue;
      if (t.s < si.start || t.e > si.end) continue;
      const prevV = toks[k - 1] ? toks[k - 1].v : undefined;
      const nextV = toks[k + 1] ? toks[k + 1].v : undefined;
      if (prevV === '.' || prevV === '?.') continue;
      if (nextV === ':' && (prevV === '{' || prevV === ',')) continue;
      refs.add(t.v);
    }
    map.set(si.i, refs);
  }
  mod._stmtRefs = map;
  return map;
}

/* ============ 5. 链接(作用域提升 + 改名) ============ */

const g = (modId, name) => `m${modId}$${name}`;

export function link(graph, shake, opts = {}) {
  const emit = opts.emitter === 'naive' ? emitModuleNaive : emitModule;
  const M = graph.modules;
  const pieces = [];
  const renamesByMod = new Map();
  let nsShimBytes = 0;

  // 每个模块建立改名表:本地名 → 全局名
  for (const mod of M) {
    if (!shake.included.has(mod.id)) continue;
    const rn = new Map();
    for (const d of mod.scan.decls) rn.set(d, g(mod.id, d));
    for (const imp of mod.scan.imports) {
      for (const sp of imp.specifiers) {
        if (sp.imported === '*') { rn.set(sp.local, g(mod.id, sp.local)); continue; }
        const t = resolveSpec(graph.fs, mod.path, imp.source);
        if (!t) continue;
        const tid = graph.byPath.get(t).id;
        const target = resolveExport(graph, tid, sp.imported);
        if (target) rn.set(sp.local, g(target.modId, target.local));
      }
    }
    renamesByMod.set(mod.id, rn);
  }

  // 命名空间导入:生成一个对象字面量把目标模块的导出聚起来。
  // 必须挂成"导入方模块的 prelude",否则分块时会被丢掉(这个坑真踩过:
  // 单块构建看起来正常,一旦走 chunkCode 重新拼装 ns 声明就凭空消失)。
  const nsDeclsByMod = new Map();
  for (const mod of M) {
    if (!shake.included.has(mod.id)) continue;
    const kept = shake.keptStmts.get(mod.id);
    if (!kept) continue;
    for (const imp of mod.scan.imports) {
      const t = resolveSpec(graph.fs, mod.path, imp.source);
      if (!t) continue;
      const tid = graph.byPath.get(t).id;
      for (const sp of imp.specifiers) {
        if (sp.imported !== '*') continue;
        const fields = allExportNames(graph, tid)
          .map((n) => {
            const r = resolveExport(graph, tid, n);
            return r ? `${JSON.stringify(n)}: ${g(r.modId, r.local)}` : null;
          })
          .filter(Boolean);
        const line = `const ${g(mod.id, sp.local)} = { ${fields.join(', ')} };`;
        nsShimBytes += line.length + 1;
        const arr = nsDeclsByMod.get(mod.id) ?? [];
        arr.push(line);
        nsDeclsByMod.set(mod.id, arr);
      }
    }
  }

  // 依赖先于依赖者
  for (const id of graph.order) {
    if (!shake.included.has(id)) continue;
    const mod = M[id];
    const kept = shake.keptStmts.get(id) ?? new Set();
    const code = emit(mod, kept, renamesByMod.get(id), mod.scan);
    const prelude = (nsDeclsByMod.get(id) ?? []).join('\n');
    const full = [prelude, code].filter((x) => x && x.trim()).join('\n');
    if (!full.trim()) continue;
    pieces.push({ modId: id, path: mod.path, code: full });
  }

  const code = pieces.map((p) => p.code).join('\n');
  return {
    code, pieces, nsShimBytes,
    stats: { bytes: code.length, pieces: pieces.length },
  };
}

/**
 * 把一个模块按保留语句 emit 出来:删 import/export 包装 + 改名。
 *
 * 这里是全流程最容易写出 O(n²) 的地方:
 * 第一版对"每条语句"都线性扫一遍全部 token 来标记删除区间,
 * 复杂度 = 语句数 × token 数。3000 模块实测链接阶段占全链路 46%,
 * 比词法+解析还贵。改成"token 单走一遍 + 语句指针推进"后为 O(token)。
 */
function emitModule(mod, kept, renames, scan) {
  const src = mod.source;
  const toks = scan.toks;
  const stmts = (mod.stmtInfo ?? scanStatements(mod, scan)).slice()
    .sort((a, b) => a.start - b.start);

  const action = new Uint8Array(toks.length); // 1 = 删除
  const insertBefore = new Map();
  const firstTok = new Map(); // stmtIndex -> 首 token 下标

  let ptr = 0;
  for (let k = 0; k < toks.length; k++) {
    const t = toks[k];
    while (ptr < stmts.length && stmts[ptr].end <= t.s) ptr++;
    if (ptr >= stmts.length) break;
    const si = stmts[ptr];
    if (t.s < si.start || t.e > si.end) continue; // 语句之外
    if (!firstTok.has(si.i)) firstTok.set(si.i, k);
    const k0 = firstTok.get(si.i);

    if (!kept.has(si.i)) { action[k] = 1; continue; }
    if (si.kind === 'import' || si.kind === 'export-list' || si.kind === 'export-star') {
      action[k] = 1;
      continue;
    }
    if (si.kind === 'export-decl') {
      // 只删 export 关键字,声明本体留下
      if (k === k0) action[k] = 1;
      continue;
    }
    if (si.kind === 'export-default') {
      // 具名 function/class → 连同 default 一起删;否则补 `const mN$__default = `
      const namedFnClass = toks[k0 + 2] && ['function', 'class', 'async'].includes(toks[k0 + 2].v)
        && toks[k0 + 3] && toks[k0 + 3].t === 'id';
      if (k === k0 || k === k0 + 1) action[k] = 1;
      else if (k === k0 + 2 && !namedFnClass) insertBefore.set(k, `const ${g(mod.id, '__default')} = `);
    }
  }

  let out = '';
  let prevEnd = 0;
  for (let k = 0; k < toks.length; k++) {
    const t = toks[k];
    const gap = src.slice(prevEnd, t.s);
    if (action[k] === 1) {
      const nl = countNewlines(gap);
      out += nl ? '\n'.repeat(nl) : ' ';
      prevEnd = t.e;
      continue;
    }
    out += gap;
    if (insertBefore.has(k)) out += insertBefore.get(k);
    let rep;
    if (t.t === 'tpl' && t.parts) rep = renameSnippet(t.v, renames);
    else rep = rewriteToken(t, toks, k, renames);
    out += rep;
    prevEnd = t.e;
  }
  out += src.slice(prevEnd);
  return clean(out);
}

/** 第一版的 O(语句×token) 实现,仅保留给 bench 做"改前 vs 改后"对照 */
export function emitModuleNaive(mod, kept, renames, scan) {
  const src = mod.source;
  const toks = scan.toks;
  const action = new Map();
  const insertBefore = new Map();
  for (const si of (mod.stmtInfo ?? scanStatements(mod, scan))) {
    if (!kept.has(si.i)) {
      for (let k = 0; k < toks.length; k++) {
        const t = toks[k];
        if (t.s >= si.start && t.e <= si.end) action.set(k, 1);
      }
      continue;
    }
    if (si.kind === 'import' || si.kind === 'export-list' || si.kind === 'export-star') {
      for (let k = 0; k < toks.length; k++) {
        const t = toks[k];
        if (t.s >= si.start && t.e <= si.end) action.set(k, 1);
      }
      continue;
    }
    let k0 = -1;
    for (let k = 0; k < toks.length; k++) if (toks[k].s === si.start) { k0 = k; break; }
    if (k0 < 0) continue;
    if (toks[k0].v === 'export') {
      if (si.kind === 'export-default') {
        const namedFnClass = toks[k0 + 2] && ['function', 'class', 'async'].includes(toks[k0 + 2].v)
          && toks[k0 + 3] && toks[k0 + 3].t === 'id';
        action.set(k0, 1); action.set(k0 + 1, 1);
        if (!namedFnClass) insertBefore.set(k0 + 2, `const ${g(mod.id, '__default')} = `);
      } else {
        action.set(k0, 1);
      }
    }
  }
  let out = '';
  let prevEnd = 0;
  for (let k = 0; k < toks.length; k++) {
    const t = toks[k];
    const gap = src.slice(prevEnd, t.s);
    if (action.get(k) === 1) {
      const nl = countNewlines(gap);
      out += nl ? '\n'.repeat(nl) : ' ';
      prevEnd = t.e;
      continue;
    }
    out += gap;
    if (insertBefore.has(k)) out += insertBefore.get(k);
    out += (t.t === 'tpl' && t.parts) ? renameSnippet(t.v, renames) : rewriteToken(t, toks, k, renames);
    prevEnd = t.e;
  }
  out += src.slice(prevEnd);
  return clean(out);
}

function countNewlines(s) {
  let n = 0;
  for (let i = 0; i < s.length; i++) if (s.charCodeAt(i) === 10) n++;
  return n;
}

function clean(s) {
  // 折叠多余空白行,避免删语句留下大段空白把体积算虚
  return s.replace(/[ \t]+\n/g, '\n').replace(/\n{3,}/g, '\n\n').trim();
}

function scanStatements(mod, scan) {
  return scan.statements.map((st, i) => ({
    i, kind: st.kind, names: st.names, start: st.start, end: st.end,
  }));
}

/**
 * 对一段独立源码片段做改名(模板字符串的 ${...} 插值用)。
 * 词法改名最容易漏的就是插值:`${value}` 里的 value 不走这个递归就会留下原名 → ReferenceError。
 */
export function renameSnippet(text, renames) {
  if (!renames.size) return text;
  const toks = tokenizeSnippet(text);
  let out = '';
  let prevEnd = 0;
  for (let k = 0; k < toks.length; k++) {
    const t = toks[k];
    out += text.slice(prevEnd, t.s);
    if (t.t === 'tpl' && t.parts) {
      let rebuilt = '';
      let last = t.s;
      for (const part of t.parts) {
        if (part.expr) {
          rebuilt += text.slice(last, part.es) + renameSnippet(text.slice(part.es, part.ee), renames);
          last = part.ee;
        }
      }
      rebuilt += text.slice(last, t.e);
      out += rebuilt;
    } else {
      out += rewriteToken(t, toks, k, renames);
    }
    prevEnd = t.e;
  }
  out += text.slice(prevEnd);
  return out;
}

/** 单 token 改名:处理属性访问、对象键、简写属性 */
function rewriteToken(t, toks, k, renames) {
  if (t.t !== 'id') return t.v;
  const prevV = toks[k - 1] ? toks[k - 1].v : undefined;
  const nextV = toks[k + 1] ? toks[k + 1].v : undefined;
  if (prevV === '.' || prevV === '?.') return t.v;
  if (nextV === ':' && (prevV === '{' || prevV === ',')) return t.v; // 对象键
  const target = renames.get(t.v);
  if (!target) return t.v;
  // 简写属性 { foo } → { foo: m0$foo }
  const prevIsOpen = prevV === '{' || prevV === ',';
  const nextIsClose = nextV === '}' || nextV === ',';
  if (prevIsOpen && nextIsClose) return `${t.v}: ${target}`;
  return target;
}

/* ============ 6. 分块 ============ */

/**
 * 四种分块策略,用来对比"改一个字节会让多少字节的缓存失效"。
 *  - single: 全部一个块
 *  - vendor: node_modules 单独一块
 *  - shared: 被 ≥2 个入口引用的模块进共享块
 *  - per-module: 一模块一块(极端情况)
 */
export function planChunks(graph, shake, strategy) {
  const includedIds = [...shake.included];
  const isVendor = (m) => m.path.startsWith('node_modules/');

  if (strategy === 'single') {
    return [{ name: 'bundle', modIds: includedIds }];
  }
  if (strategy === 'vendor') {
    const v = includedIds.filter((id) => isVendor(graph.modules[id]));
    const a = includedIds.filter((id) => !isVendor(graph.modules[id]));
    const chunks = [];
    if (a.length) chunks.push({ name: 'app', modIds: a });
    if (v.length) chunks.push({ name: 'vendor', modIds: v });
    return chunks;
  }
  if (strategy === 'per-module') {
    return includedIds.map((id) => ({ name: `m${id}`, modIds: [id] }));
  }
  // shared:统计每个模块被多少个入口可达(这里以"被多少个模块依赖"近似引用计数)
  const refCount = new Map();
  for (const mod of graph.modules) {
    if (!shake.included.has(mod.id)) continue;
    for (const d of mod.deps) {
      if (d.targetId == null) continue;
      refCount.set(d.targetId, (refCount.get(d.targetId) ?? 0) + 1);
    }
  }
  const shared = [];
  const own = [];
  for (const id of includedIds) {
    if (id === graph.entryId) { own.push(id); continue; }
    if ((refCount.get(id) ?? 0) >= 2) shared.push(id); else own.push(id);
  }
  const chunks = [];
  if (own.length) chunks.push({ name: 'entry', modIds: own });
  if (shared.length) chunks.push({ name: 'shared', modIds: shared });
  return chunks;
}

/** 把 chunk 的模块代码拼起来(用于算体积与哈希) */
export function chunkCode(linkResult, chunk) {
  const set = new Set(chunk.modIds);
  return linkResult.pieces.filter((p) => set.has(p.modId)).map((p) => p.code).join('\n');
}

/* ============ 7. 内容哈希 ============ */

const FNV_OFFSET = 0xcbf29ce484222325n;
const FNV_PRIME = 0x100000001b3n;
const MASK64 = (1n << 64n) - 1n;

/** FNV-1a 64,按 UTF-16 code unit 处理(真实工具用 xxhash/content hash,思路一致) */
export function contentHash(str) {
  let h = FNV_OFFSET;
  for (let i = 0; i < str.length; i++) {
    h ^= BigInt(str.charCodeAt(i) & 0xff);
    h = (h * FNV_PRIME) & MASK64;
    h ^= BigInt(str.charCodeAt(i) >> 8);
    h = (h * FNV_PRIME) & MASK64;
  }
  return h.toString(16).padStart(16, '0');
}

/** 双通道 FNV-1a 32 位(Math.imul)拼 64 位:比 BigInt 版快一个量级 */
export function contentHashFast(str) {
  let h1 = 0x811c9dc5, h2 = 0x01000193;
  for (let i = 0; i < str.length; i++) {
    const c = str.charCodeAt(i);
    h1 = Math.imul(h1 ^ (c & 0xff), 0x01000193);
    h2 = Math.imul(h2 ^ (c >>> 8), 0x01000193);
  }
  return ((h1 >>> 0).toString(16).padStart(8, '0')) + ((h2 >>> 0).toString(16).padStart(8, '0'));
}

/* ============ 8. 一键构建 ============ */

export function build(files, entry, opts = {}) {
  const fs = createFS(files);
  const graph = buildGraph(fs, entry, { cache: opts.cache });
  const shake = treeShake(graph);
  const linked = link(graph, shake);
  // 把入口导出挂到调用方给的变量上,方便测试里直接取结果做差分对照。
  // 注意要加在"包含入口模块的那个 chunk"尾部,加在 linked.code 上会被分块丢掉。
  const exposeTail = opts.expose
    ? (() => {
        const fields = allExportNames(graph, graph.entryId).map((n) => {
          const r = resolveExport(graph, graph.entryId, n);
          return r ? `${JSON.stringify(n)}: ${g(r.modId, r.local)}` : null;
        }).filter(Boolean);
        return `\n;${opts.expose} = { ${fields.join(', ')} };`;
      })()
    : '';
  const strategy = opts.strategy ?? 'single';
  const chunks = planChunks(graph, shake, strategy);
  const emitted = chunks.map((c) => {
    let code = chunkCode(linked, c);
    if (exposeTail && c.modIds.includes(graph.entryId)) code += exposeTail;
    return { name: c.name, modIds: c.modIds, code, bytes: code.length, hash: contentHash(code) };
  });
  return { graph, shake, linked, chunks: emitted, unresolved: graph.unresolved };
}

export { norm, dirname, uid };

scan.mjs —— 静态分析(依赖扫描、tree-shaking 可达性分析)

// scan.mjs —— 零依赖 ESM 词法器 + 模块语法扫描器
// 目标:不做完整 AST,但把"决定打包结果"的那部分语法精确切出来
//   import/export 语句、顶层绑定、动态 import、顶层副作用

/* ============ 1. 词法器 ============ */

const PUNC3 = ['...', '===', '!==', '**=', '<<=', '>>=', '>>>', '&&=', '||=', '??='];
const PUNC2 = [
  '=>', '==', '!=', '<=', '>=', '&&', '||', '??', '?.', '++', '--',
  '+=', '-=', '*=', '/=', '%=', '&=', '|=', '^=', '**', '<<', '>>',
];
// 这些关键字后面出现的 / 一定是正则,不是除法
const REGEX_PREV_KW = new Set([
  'return', 'typeof', 'instanceof', 'in', 'of', 'new', 'delete', 'void',
  'throw', 'case', 'do', 'else', 'yield', 'await',
]);
// 这些关键字后面的 ( ... ) 是"控制条件括号",其 ) 后面允许正则
const CONTROL_KW = new Set(['if', 'while', 'for', 'with', 'switch', 'catch']);

const isIdStart = (c) => /[A-Za-z_$#]/.test(c) || c > '\x7f';
const isIdPart = (c) => /[A-Za-z0-9_$]/.test(c) || c > '\x7f';
const isDigit = (c) => c >= '0' && c <= '9';

/**
 * 把源码切成 token。
 * token: { t, v, s, e }
 *   t: 'id' | 'num' | 'str' | 'tpl' | 're' | 'punc'
 *   tpl 额外带 parts: [{ s, e } | { expr: token[] }]
 * 全局维护 parenStack,用来判断 ) 之后能不能是正则。
 */
export function tokenize(src) {
  const toks = [];
  const n = src.length;
  let i = 0;
  // 括号栈:记录每个 ( 是否属于控制语句
  const parens = [];
  // 记录上一个"有效"token,用于正则歧义消解
  let prev = null;

  const push = (tok) => {
    toks.push(tok);
    prev = tok;
    return tok;
  };

  // 只读前瞻:找前一个非空白字符
  const lastSignificant = () => prev;

  const regexAllowed = () => {
    const p = lastSignificant();
    if (!p) return true;                       // 文件开头
    if (p.t === 'num' || p.t === 'str' || p.t === 'tpl' || p.t === 're') return false;
    if (p.t === 'id') return REGEX_PREV_KW.has(p.v);
    if (p.t === 'punc') {
      if (p.v === ')') return p.ctrl === true;  // if(...)/for(...) 的 ) → 允许
      if (p.v === ']') return false;
      if (p.v === '}') return true;             // 块结束,保守放行
      if (p.v === '++' || p.v === '--') return false;
      return true;
    }
    return true;
  };

  // 扫描模板字符串,递归扫描 ${ } 内部表达式
  function scanTemplate(start) {
    // start 指向反引号
    let j = start + 1;
    const parts = [];
    let segStart = j;
    while (j < n) {
      const c = src[j];
      if (c === '\\') { j += 2; continue; }
      if (c === '`') {
        parts.push({ s: segStart, e: j });
        return { end: j + 1, parts };
      }
      if (c === '$' && src[j + 1] === '{') {
        parts.push({ s: segStart, e: j });
        // 递归词法化表达式部分
        const exprStart = j + 2;
        let depth = 1, k = exprStart;
        while (k < n && depth > 0) {
          const ch = src[k];
          if (ch === '\\') { k += 2; continue; }
          if (ch === '{') depth++;
          else if (ch === '}') depth--;
          if (depth === 0) break;
          // 跳过字符串,避免字符串里的 } 影响配平
          if (ch === '"' || ch === "'" || ch === '`') {
            k = skipStringLike(k);
            continue;
          }
          k++;
        }
        const inner = src.slice(exprStart, k);
        // 记录绝对区间,改名时要在插值里递归处理(词法改名最容易漏的地方)
        parts.push({ expr: tokenize(inner), es: exprStart, ee: k });
        j = k + 1;
        segStart = j;
        continue;
      }
      j++;
    }
    throw new SyntaxError('未闭合的模板字符串');
  }

  // 返回结束后的下标(指向闭合引号之后)
  function skipStringLike(k) {
    const q = src[k];
    if (q === '`') {
      // 粗略跳过模板
      let m = k + 1;
      while (m < n) {
        if (src[m] === '\\') { m += 2; continue; }
        if (src[m] === '`') return m + 1;
        m++;
      }
      return m;
    }
    let m = k + 1;
    while (m < n) {
      const ch = src[m];
      if (ch === '\\') { m += 2; continue; }
      if (ch === q) return m + 1;
      if (ch === '\n') return m; // 未闭合,止损
      m++;
    }
    return m;
  }

  while (i < n) {
    const c = src[i];

    // 空白
    if (c === ' ' || c === '\t' || c === '\r' || c === '\n' || c === '\uFEFF') { i++; continue; }

    // 注释
    if (c === '/' && src[i + 1] === '/') {
      while (i < n && src[i] !== '\n') i++;
      continue;
    }
    if (c === '/' && src[i + 1] === '*') {
      const end = src.indexOf('*/', i + 2);
      i = end === -1 ? n : end + 2;
      continue;
    }

    // 模板字符串
    if (c === '`') {
      const { end, parts } = scanTemplate(i);
      push({ t: 'tpl', v: src.slice(i, end), s: i, e: end, parts });
      i = end;
      continue;
    }

    // 普通字符串
    if (c === '"' || c === "'") {
      let j = i + 1;
      while (j < n) {
        const ch = src[j];
        if (ch === '\\') { j += 2; continue; }
        if (ch === c) { j++; break; }
        j++;
      }
      push({ t: 'str', v: src.slice(i, j), s: i, e: j });
      i = j;
      continue;
    }

    // 数字
    if (isDigit(c) || (c === '.' && isDigit(src[i + 1]))) {
      let j = i;
      if (c === '0' && /[xXbBoO]/.test(src[i + 1] || '')) {
        j = i + 2;
        while (j < n && /[0-9a-fA-F_]/.test(src[j])) j++;
      } else {
        while (j < n && /[0-9_]/.test(src[j])) j++;
        if (src[j] === '.') { j++; while (j < n && /[0-9_]/.test(src[j])) j++; }
        if (/[eE]/.test(src[j] || '')) {
          const save = j; j++;
          if (/[+-]/.test(src[j] || '')) j++;
          if (isDigit(src[j] || '')) { while (j < n && /[0-9_]/.test(src[j])) j++; }
          else j = save;
        }
      }
      if (src[j] === 'n') j++;
      push({ t: 'num', v: src.slice(i, j), s: i, e: j });
      i = j;
      continue;
    }

    // 标识符 / 关键字
    if (isIdStart(c)) {
      let j = i + 1;
      while (j < n && isIdPart(src[j])) j++;
      push({ t: 'id', v: src.slice(i, j), s: i, e: j });
      i = j;
      continue;
    }

    // 正则字面量(歧义消解后)
    if (c === '/' && regexAllowed()) {
      let j = i + 1;
      let inClass = false;
      while (j < n) {
        const ch = src[j];
        if (ch === '\\') { j += 2; continue; }
        if (ch === '[') inClass = true;
        else if (ch === ']') inClass = false;
        else if (ch === '/' && !inClass) { j++; break; }
        else if (ch === '\n') break;
        j++;
      }
      while (j < n && /[a-z]/.test(src[j])) j++; // flags
      push({ t: 're', v: src.slice(i, j), s: i, e: j });
      i = j;
      continue;
    }

    // 标点(最长匹配)
    let matched = null;
    for (const p of PUNC3) if (src.startsWith(p, i)) { matched = p; break; }
    if (!matched) for (const p of PUNC2) if (src.startsWith(p, i)) { matched = p; break; }
    if (!matched) matched = c;

    if (matched === '(') {
      const p = lastSignificant();
      parens.push(p && p.t === 'id' && CONTROL_KW.has(p.v));
      push({ t: 'punc', v: '(', s: i, e: i + 1, ctrl: parens[parens.length - 1] });
    } else if (matched === ')') {
      const ctrl = parens.length ? parens.pop() : false;
      push({ t: 'punc', v: ')', s: i, e: i + 1, ctrl });
    } else {
      push({ t: 'punc', v: matched, s: i, e: i + matched.length });
    }
    i += matched.length;
  }
  return toks;
}

/* ============ 2. 模块语法扫描 ============ */

/**
 * 从 token 流里抽出模块级事实。
 * 只认顶层(花括号深度 0)的 import/export/声明。
 */
export function scanModule(src) {
  const toks = tokenize(src);
  const imports = [];      // { source, specifiers:[{imported:'default'|'*'|name, local}], sideEffectOnly }
  const exports = [];      // { exported, local?|expression?|source?|imported? }
  const decls = [];        // 顶层声明的裸名(用于建立本模块绑定表)
  const dynamicImports = [];
  const topLevelStmts = []; // 顶层语句首 token 分类,用于判断副作用
  const statements = [];    // { kind, start, end, names } 字符区间,打包时用来整段删除

  let depth = 0;
  let i = 0;
  const at = (k) => toks[k];
  const v = (k) => (toks[k] ? toks[k].v : undefined);

  // 动态 import 可能在任意嵌套里,且可能出现在被整段跳过的语句中,
  // 所以单独做一次全量预扫(廉价,只找 import 后跟括号且非 .meta)。
  for (let k = 0; k < toks.length; k++) {
    const tk = toks[k];
    if (tk.t !== 'id' || tk.v !== 'import') continue;
    const nx = toks[k + 1];
    if (!nx || nx.t !== 'punc' || nx.v !== '(') continue;
    const arg = toks[k + 2];
    if (arg && arg.t === 'str') dynamicImports.push(unquote(arg.v));
    else dynamicImports.push(null); // 非字面量:真实构建里无法静态分析,只能单独成块
  }

  const pushStmt = (kind, sIdx, eIdx, names = []) => {
    const a = toks[sIdx], b = toks[eIdx];
    if (!a || !b) return;
    statements.push({ kind, start: a.s, end: b.e, names });
  };

  // 从 k 开始找语句结束。
  // 关键坑:`export function f(){}` 没有分号,若只找 ';' 会一路吞掉后面整段代码。
  // 所以要记录"语句内是否开启过顶层花括号",一旦花括号回落到 0 就视为结束。
  // groupAt:import/export 的 { ... } 说明符列表不是语句块,必须排除,
  // 否则 `import {a} from 'x'` 会被误判为在 '}' 处结束(真实踩过的坑)。
  const stmtEnd = (k, groupAt = -1) => {
    let d = 0;
    let openedBlock = false;
    for (let m = k; m < toks.length; m++) {
      const t = toks[m];
      if (t.t !== 'punc') continue;
      if (t.v === '(' || t.v === '[') d++;
      else if (t.v === ')' || t.v === ']') { d--; if (d < 0) return m; }
      else if (t.v === '{') { if (m !== groupAt && d === 0) openedBlock = true; d++; }
      else if (t.v === '}') {
        d--;
        if (d < 0) return m;
        if (d === 0 && openedBlock) return m;
      } else if (t.v === ';' && d === 0) return m;
    }
    return toks.length - 1;
  };

  // 解析 { a, b as c } → [[imported, local], ...]
  const parseSpecList = (k) => {
    // k 指向 {
    const out = [];
    let m = k + 1;
    while (m < toks.length && v(m) !== '}') {
      if (at(m).t === 'id') {
        const imported = at(m).v;
        let local = imported;
        if (v(m + 1) === 'as' && at(m + 2)) { local = at(m + 2).v; m += 2; }
        out.push({ imported, local });
      }
      m++;
    }
    return { list: out, next: m + 1 };
  };

  while (i < toks.length) {
    const t = toks[i];

    if (t.t === 'punc') {
      if ('({['.includes(t.v)) depth++;
      else if (')}]'.includes(t.v)) depth--;
      i++;
      continue;
    }

    // 动态 import(...) 任意深度都要抓
    if (t.t === 'id' && t.v === 'import' && v(i + 1) === '(') {
      const arg = at(i + 2);
      // import.meta 不是动态导入
      if (v(i + 1) === '.' && v(i + 2) === 'meta') { i += 3; continue; }
      if (arg && arg.t === 'str') dynamicImports.push(unquote(arg.v));
      i += 2;
      continue;
    }

    if (depth !== 0) { i++; continue; }

    // ---- 顶层 import ----
    if (t.t === 'id' && t.v === 'import' && v(i + 1) !== '.' && v(i + 1) !== '(') {
      const start = i;
      let m = i + 1;
      const specifiers = [];
      let braceAt = -1;
      // import 'x'
      if (at(m) && at(m).t === 'str') {
        imports.push({ source: unquote(at(m).v), specifiers: [], sideEffectOnly: true });
        pushStmt('import', start, stmtEnd(start));
        i = stmtEnd(start) + 1;
        continue;
      }
      // 默认导入
      if (at(m) && at(m).t === 'id' && at(m).v !== 'from') {
        specifiers.push({ imported: 'default', local: at(m).v });
        m++;
        if (v(m) === ',') m++;
      } else if (at(m) && at(m).t === 'id' && at(m).v === 'from') {
        // 不该发生
      }
      // 命名空间或具名
      if (v(m) === '*') {
        const asName = v(m + 2);
        specifiers.push({ imported: '*', local: asName });
        m += 3;
      } else if (v(m) === '{') {
        braceAt = m;
        const { list, next } = parseSpecList(m);
        specifiers.push(...list.map((x) => ({ imported: x.imported, local: x.local })));
        m = next;
      }
      const end = stmtEnd(start, braceAt);
      // from 'x'
      while (m <= end && v(m) !== 'from') m++;
      const srcTok = at(m + 1);
      if (srcTok && srcTok.t === 'str') {
        imports.push({ source: unquote(srcTok.v), specifiers, sideEffectOnly: false });
      }
      pushStmt('import', start, end);
      i = end + 1;
      continue;
    }

    // ---- 顶层 export ----
    if (t.t === 'id' && t.v === 'export') {
      let m = i + 1;
      // export default
      if (v(m) === 'default') {
        let defaultLocal = null;
        const end = stmtEnd(i);
        // export default function foo(){} / class Foo{}
        const nx = at(m + 1);
        if (nx && nx.t === 'id' && (nx.v === 'function' || nx.v === 'class')) {
          let p = m + 2;
          if (v(p) === '*') p++;
          if (at(p) && at(p).t === 'id') {
            decls.push(at(p).v);
            exports.push({ exported: 'default', local: at(p).v });
            defaultLocal = at(p).v;
          } else {
            // 匿名默认导出:合成一个本地名,链接期才好引用
            decls.push('__default');
            exports.push({ exported: 'default', local: '__default', anonymous: true });
            defaultLocal = '__default';
          }
        } else if (nx && nx.t === 'id' && nx.v === 'async') {
          let p = m + 3;
          if (at(p) && at(p).t === 'id') {
            decls.push(at(p).v);
            exports.push({ exported: 'default', local: at(p).v });
            defaultLocal = at(p).v;
          } else { decls.push('__default'); exports.push({ exported: 'default', local: '__default', anonymous: true }); }
        } else {
          exports.push({ exported: 'default', local: '__default', expression: toks.slice(m + 1, end).map((x) => x.v).join(' ') });
          decls.push('__default');
          defaultLocal = '__default';
        }
        pushStmt('export-default', i, end, [defaultLocal ?? '__default']);
        i = end + 1;
        continue;
      }
      // export * from 'x' / export * as ns from 'x'
      if (v(m) === '*') {
        if (v(m + 1) === 'as') {
          const ns = v(m + 2);
          const end = stmtEnd(i);
          let p = m + 3;
          while (p <= end && v(p) !== 'from') p++;
          const s = at(p + 1);
          if (s && s.t === 'str') exports.push({ exported: ns, source: unquote(s.v), imported: '*' });
          pushStmt('export-star', i, end, [ns]);
          i = end + 1;
          continue;
        }
        const end = stmtEnd(i);
        const s = at(m + 2);
        if (s && s.t === 'str') exports.push({ exported: '*', source: unquote(s.v), imported: '*' });
        pushStmt('export-star', i, end, ['*']);
        i = end + 1;
        continue;
      }
      // export { ... } [from 'x']
      if (v(m) === '{') {
        const braceAt = m;
        const { list, next } = parseSpecList(m);
        const end = stmtEnd(i, braceAt);
        let p = next;
        while (p <= end && v(p) !== 'from') p++;
        if (v(p) === 'from') {
          const s = at(p + 1);
          const source = s && s.t === 'str' ? unquote(s.v) : null;
          for (const x of list) exports.push({ exported: x.local, imported: x.imported, source });
        } else {
          for (const x of list) exports.push({ exported: x.local, local: x.imported });
        }
        pushStmt('export-list', i, end, list.map((x) => x.imported));
        i = end + 1;
        continue;
      }
      // export const/let/var/function/class/async function
      const kw = v(m);
      if (kw === 'const' || kw === 'let' || kw === 'var') {
        // 只取第一个/最简单的标识符;解构声明交给 AST 才能准确,这里记录能力边界
        let p = m + 1;
        const names = [];
        let d = 0;
        const end = stmtEnd(i);
        while (p <= end) {
          const tk = at(p);
          if (tk.t === 'punc') {
            if ('([{'.includes(tk.v)) d++;
            else if (')]}'.includes(tk.v)) d--;
          } else if (tk.t === 'id' && d === 0) {
            // declarator 起始位置的标识符
            const prevV = v(p - 1);
            if (p === m + 1 || prevV === ',') names.push(tk.v);
          }
          p++;
        }
        for (const nm of names) {
          decls.push(nm);
          exports.push({ exported: nm, local: nm });
        }
        pushStmt('export-decl', i, end, names);
        i = end + 1;
        continue;
      }
      if (kw === 'function' || kw === 'class') {
        let p = m + 1;
        if (v(p) === '*') p++;
        if (at(p) && at(p).t === 'id') {
          decls.push(at(p).v);
          exports.push({ exported: at(p).v, local: at(p).v });
        }
        const end = stmtEnd(i);
        pushStmt('export-decl', i, end, at(p) && at(p).t === 'id' ? [at(p).v] : []);
        i = end + 1;
        continue;
      }
      if (kw === 'async') {
        let p = m + 3;
        if (at(p) && at(p).t === 'id') {
          decls.push(at(p).v);
          exports.push({ exported: at(p).v, local: at(p).v });
        }
        const end = stmtEnd(i);
        i = end + 1;
        continue;
      }
      i = m;
      continue;
    }

    // ---- 顶层声明(非 export) ----
    if (t.t === 'id' && (t.v === 'function' || t.v === 'class')) {
      let p = i + 1;
      if (v(p) === '*') p++;
      if (at(p) && at(p).t === 'id') decls.push(at(p).v);
      i++;
      continue;
    }
    if (t.t === 'id' && t.v === 'async' && v(i + 1) === 'function') {
      let p = i + 2;
      if (v(p) === '*') p++;
      if (at(p) && at(p).t === 'id') decls.push(at(p).v);
      i += 2;
      continue;
    }
    if (t.t === 'id' && (t.v === 'const' || t.v === 'let' || t.v === 'var')) {
      const end = stmtEnd(i);
      let p = i + 1;
      let d = 0;
      while (p <= end) {
        const tk = at(p);
        if (tk.t === 'punc') {
          if ('([{'.includes(tk.v)) d++;
          else if (')]}'.includes(tk.v)) d--;
        } else if (tk.t === 'id' && d === 0) {
          const prevV = v(p - 1);
          if (p === i + 1 || prevV === ',') decls.push(tk.v);
        }
        p++;
      }
      i = end + 1;
      continue;
    }

    // 顶层其他语句 → 副作用标记 + 登记为 statement(必须 emit,否则副作用丢失)
    topLevelStmts.push(t ? t.v : '');
    {
      const end = stmtEnd(i);
      pushStmt('statement', i, end);
      i = end + 1;
    }
  }

  // 本模块内被引用到的标识符(不含声明位),tree-shaking 判存活要用。
  // 两个坑:
  //  1) 模板字符串的 ${...} 是嵌套 token 数组,扁平遍历会漏掉里面的引用;
  //  2) 声明位(const x / function f / 解构绑定)不是引用。
  // 这里刻意做得"保守"(多算引用 = 少删代码 = 不会删错)。
  const referenced = new Set();
  {
    const flatten = (list, out = []) => {
      for (const tk of list) {
        if (tk.t === 'tpl' && tk.parts) {
          for (const pt of tk.parts) if (pt.expr) flatten(pt.expr, out);
          continue;
        }
        out.push(tk);
      }
      return out;
    };
    const flat = flatten(toks);
    const DECL_KW = new Set(['const', 'let', 'var']);
    let inDeclarator = false;
    for (let k = 0; k < flat.length; k++) {
      const t = flat[k];
      if (t.t === 'punc' && t.v === ';') { inDeclarator = false; continue; }
      if (t.t !== 'id') continue;
      const prevV = flat[k - 1] ? flat[k - 1].v : undefined;
      const nextV = flat[k + 1] ? flat[k + 1].v : undefined;
      if (DECL_KW.has(t.v)) { inDeclarator = true; continue; }
      if (prevV === '.' || prevV === '?.') continue;                   // 属性访问不是引用
      if (nextV === ':' && (prevV === '{' || prevV === ',')) continue;  // 对象字面量的键
      const isDeclName = prevV === 'function' || prevV === 'class' ||
        prevV === 'async' || prevV === '*' ||
        (inDeclarator && (prevV === ',' || DECL_KW.has(prevV ?? '') || flat[k - 1]?.t === 'id'));
      if (isDeclName) continue;
      referenced.add(t.v);
    }
  }

  return { toks, imports, exports, decls, dynamicImports, topLevelStmts, statements, referenced };
}

function unquote(s) {
  // 只处理无转义的常见情况,其余交给 JSON/原生解析
  if (s[0] === '"' || s[0] === "'") {
    const body = s.slice(1, -1);
    if (!body.includes('\\')) return body;
    return body.replace(/\\(.)/g, (_, c) => ({ n: '\n', t: '\t', r: '\r' }[c] ?? c));
  }
  return s;
}

/* ============ 3. 顶层副作用判定 ============ */

const BENIGN_TOP = new Set([';', 'const', 'let', 'var', 'function', 'class', 'async', 'import', 'export']);

/**
 * 模块是否有顶层副作用(用于 tree-shaking 是否可整块丢弃)。
 * 真实世界里这等价于 package.json 的 sideEffects 字段,但可以由源码推导。
 */
export function hasSideEffects(scan) {
  for (const s of scan.topLevelStmts) {
    if (!BENIGN_TOP.has(s)) return true;
  }
  return false;
}

fixtures.mjs —— 测试夹具(多个模块图场景)

// fixtures.mjs —— 夹具生成器:确定性合成项目 + 落盘真实 ESM(用于和原生 import 做差分对照)

export function mulberry32(seed) {
  let a = seed >>> 0;
  return () => {
    a = (a + 0x6d2b79f5) >>> 0;
    let t = a;
    t = Math.imul(t ^ (t >>> 15), t | 1);
    t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
    return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
  };
}

/**
 * 合成项目:
 *  - N 个模块,每个导出 live_<i> / dead_<i>
 *  - 入口只引用其中一部分 → 其余应该被 tree-shaking 掉
 *  - 依赖指向比自身编号小的模块(保证无环,可选制造环)
 */
export function genProject({ modules = 100, liveRatio = 0.3, withCycles = false, seed = 7 } = {}) {
  const rnd = mulberry32(seed);
  const files = {};
  const liveCount = Math.max(1, Math.round(modules * liveRatio));

  for (let i = 0; i < modules; i++) {
    const deps = [];
    // 每个模块引用 1~3 个更小的模块
    const fanout = 1 + Math.floor(rnd() * 3);
    for (let k = 0; k < fanout && i > 0; k++) {
      const target = Math.floor(rnd() * i);
      if (!deps.includes(target)) deps.push(target);
    }
    const lines = [];
    for (const d of deps) lines.push(`import { live_${d} } from './m${d}.mjs';`);
    lines.push(`export function live_${i}(x){`);
    if (deps.length) {
      lines.push(`  let acc = x;`);
      for (const d of deps) lines.push(`  acc = acc + live_${d}(x | ${d});`);
      lines.push(`  return acc + ${i};`);
    } else {
      lines.push(`  return x + ${i};`);
    }
    lines.push(`}`);
    // 死代码:一个从不被引用的导出,且体积不小
    lines.push(`export function dead_${i}(x){`);
    lines.push(`  // 这一段在打包后应该消失`);
    lines.push(`  const table = [${Array.from({ length: 8 }, (_, k) => `${i * 31 + k}`).join(', ')}];`);
    lines.push(`  return table.map((v) => v * x + ${i}).reduce((a, b) => a + b, 0);`);
    lines.push(`}`);
    files[`src/m${i}.mjs`] = lines.join('\n');
  }

  // 入口:只引用前 liveCount 个模块
  const entry = [];
  const used = [];
  for (let i = 0; i < liveCount; i++) {
    entry.push(`import { live_${i} } from './m${i}.mjs';`);
    used.push(i);
  }
  entry.push(`export const total = [${used.map((i) => `live_${i}(${i})`).join(', ')}].reduce((a, b) => a + b, 0);`);
  files['src/main.mjs'] = entry.join('\n');

  if (withCycles) {
    // a ↔ b 互引:函数声明可提升,运行期合法
    files['src/cyc/a.mjs'] = `import { b } from './b.mjs';\nexport function a(n){ return n <= 0 ? 'a' : 'a' + b(n - 1); }`;
    files['src/cyc/b.mjs'] = `import { a } from './a.mjs';\nexport function b(n){ return n <= 0 ? 'b' : 'b' + a(n - 1); }`;
  }
  return files;
}

/** 小型"真实形状"项目:用来做语义差分对照 */
export function genSemanticProject() {
  return {
    'src/main.mjs': `
import { add, mul, ZERO } from './math.mjs';
import { label, PREFIX } from './str.mjs';
import * as all from './math.mjs';
import fmt from './fmt.mjs';
import './effect.mjs';

const composed = add(mul(2, 3), ZERO);
export const out1 = composed;
export const out2 = label(composed);
export const out3 = \`\${PREFIX}:\${fmt(composed)}\`;
export const out4 = all.mul(4, 5);
export const out5 = (() => {
  // 影子变量:内层 value 遮蔽同名导入
  const value = 999;
  return value;
})();
export const out6 = add(value0(), ZERO);
function value0(){ return 41; }
`,
    'src/math.mjs': `
export const ZERO = 0;
export function add(a, b){ return a + b; }
export function mul(a, b){ return a * b; }
export function dead(){ return 'should be shaken'; }
`,
    'src/str.mjs': `
import { add } from './math.mjs';
export const PREFIX = 'v';
export function label(n){
  const { a, b } = { a: n, b: add(n, 1) };
  return \`\${PREFIX}-\${a}-\${b}\`;
}
export function unusedLabel(){ return 'nope'; }
`,
    'src/fmt.mjs': `
export default function fmt(n){ return '[' + n + ']'; }
`,
    'src/effect.mjs': `
export const ignored = 1;
globalThis.__EFFECT_RAN__ = (globalThis.__EFFECT_RAN__ ?? 0) + 1;
`,
  };
}

/** 复导出链:export {} from / export * from 穿透 */
export function genReexportProject() {
  return {
    'src/main.mjs': `
import { add, real, deep } from './barrel.mjs';
export const r1 = add(1, 2);
export const r2 = real;
export const r3 = deep();
`,
    'src/barrel.mjs': `
export { add } from './core.mjs';
export * from './extra.mjs';
export { deep } from './depth1.mjs';
`,
    'src/core.mjs': `export function add(a, b){ return a + b; }`,
    'src/extra.mjs': `export const real = 'REAL'; export const nope = 'NOPE';`,
    'src/depth1.mjs': `export * from './depth2.mjs';`,
    'src/depth2.mjs': `export function deep(){ return 'deep-value'; }`,
  };
}

/** 只含副作用的模块:必须保留其执行顺序 */
export function genSideEffectProject() {
  return {
    'src/main.mjs': `
import './a.mjs';
import './b.mjs';
import { read } from './store.mjs';
export const order = read();
`,
    'src/a.mjs': `import { push } from './store.mjs';\npush('a');`,
    'src/b.mjs': `import { push } from './store.mjs';\npush('b');`,
    'src/store.mjs': `
const log = [];
export function push(x){ log.push(x); }
export function read(){ return log.join(','); }
`,
  };
}

/** 各种默认导出形态 + 动态 import */
export function genDefaultFormsProject() {
  return {
    'src/main.mjs': `
import expr, { cls, named, viaList } from './forms.mjs';
export const d1 = expr;
export const d2 = new cls(3).get();
export const d3 = named();
export const d4 = viaList;
`,
    'src/forms.mjs': `
const base = { cls: class C { constructor(n){ this.n = n; } get(){ return this.n * 2; } } };
export default 6 * 7;
export const cls = base.cls;
export function named(){ return 'named-value'; }
function hidden(){ return 'via-list'; }
export { hidden as viaList };
`,
  };
}

/** 落盘成真实文件树,供原生 import 使用 */
export async function materialize(files, fsMod, pathMod, osMod) {
  const dir = await fsMod.promises.mkdtemp(pathMod.join(osMod.tmpdir(), 'day35-'));
  for (const [p, content] of Object.entries(files)) {
    const full = pathMod.join(dir, p);
    await fsMod.promises.mkdir(pathMod.dirname(full), { recursive: true });
    await fsMod.promises.writeFile(full, content, 'utf8');
  }
  return dir;
}

/** 把虚拟 FS 的写盘顺序打乱(模拟 readdir 顺序不可控) */
export function shuffleEntries(files, seed = 1) {
  const rnd = mulberry32(seed);
  const arr = Object.entries(files);
  for (let i = arr.length - 1; i > 0; i--) {
    const j = Math.floor(rnd() * (i + 1));
    [arr[i], arr[j]] = [arr[j], arr[i]];
  }
  return Object.fromEntries(arr);
}

test-bundler.mjs —— 构建器断言(产物语义等价、tree-shaking 正确性)

// test-bundler.mjs —— 断言 + 与原生 ESM 的差分对照
import assert from 'node:assert/strict';
import fs from 'node:fs';
import path from 'node:path';
import os from 'node:os';
import { pathToFileURL } from 'node:url';
import { tokenize, scanModule, hasSideEffects } from './scan.mjs';
import {
  build, buildGraph, treeShake, planChunks, contentHash, createFS, resolveSpec, findCycles, topoOrder,
} from './build.mjs';
import {
  genProject, genSemanticProject, genReexportProject, genSideEffectProject,
  genDefaultFormsProject, materialize, shuffleEntries,
} from './fixtures.mjs';

let pass = 0, fail = 0;
const t = (name, fn) => {
  try { fn(); pass++; }
  catch (e) { fail++; console.error(`✗ ${name}\n  ${e.message}`); }
};
const ta = async (name, fn) => {
  try { await fn(); pass++; }
  catch (e) { fail++; console.error(`✗ ${name}\n  ${e.message}`); }
};

/* ---------- §1 词法器 ---------- */
t('正则 vs 除法:if 条件括号后的 / 是正则', () => {
  const tk = tokenize('if (a) /x/.test(y)');
  assert.ok(tk.some((x) => x.t === 're' && x.v === '/x/'));
});
t('正则 vs 除法:普通括号后的 / 是除法', () => {
  const tk = tokenize('const r = (a + b) / 2 / c');
  assert.ok(!tk.some((x) => x.t === 're'));
});
t('正则里的 } 不破坏注释/字符串切分', () => {
  const tk = tokenize('function f(){ return /}/.test("}") }');
  assert.equal(tk.filter((x) => x.t === 're').length, 1);
});
t('模板嵌套插值被完整收集', () => {
  const tk = tokenize('`a${ `b${c}` }d`');
  const tpl = tk.find((x) => x.t === 'tpl');
  assert.ok(tpl, '未识别为 tpl');
  // 外层 1 个插值 + 插值里再嵌套 1 个
  const count = (list) => list.reduce((n, tk) => {
    if (tk.t !== 'tpl' || !tk.parts) return n;
    return n + tk.parts.filter((p) => p.expr).length +
      tk.parts.reduce((m, p) => m + (p.expr ? count(p.expr) : 0), 0);
  }, 0);
  assert.equal(count(tk), 2);
});
t('bigint / 数字分隔符', () => {
  assert.equal(tokenize('1_000_000n')[0].v, '1_000_000n');
});
t('除号后面跟 / 注释不误判成正则', () => {
  const tk = tokenize('const a = b / c; // 注释\n');
  assert.equal(tk.filter((x) => x.t === 're').length, 0);
});

/* ---------- §2 模块语法扫描 ---------- */
t('import 六种形态', () => {
  const m = scanModule(`
import d from './a.mjs';
import * as ns from './b.mjs';
import { x, y as z } from './c.mjs';
import d2, { p } from './d.mjs';
import d3, * as ns2 from './e.mjs';
import './f.mjs';`);
  assert.equal(m.imports.length, 6);
  assert.deepEqual(m.imports[0].specifiers, [{ imported: 'default', local: 'd' }]);
  assert.deepEqual(m.imports[1].specifiers, [{ imported: '*', local: 'ns' }]);
  assert.equal(m.imports[2].specifiers.length, 2);
  assert.equal(m.imports[3].specifiers.length, 2);
  assert.equal(m.imports[4].specifiers.length, 2);
  assert.equal(m.imports[5].sideEffectOnly, true);
});
t('export 七种形态', () => {
  const m = scanModule(`
export const a = 1, b = 2;
export function f(){}
export class C {}
export default function g(){}
export { x as y };
export * from './r.mjs';
export * as ns from './s.mjs';`);
  const names = m.exports.map((e) => e.exported);
  assert.ok(names.includes('a') && names.includes('b'));
  assert.ok(names.includes('f') && names.includes('C'));
  assert.ok(names.includes('default') && names.includes('y'));
  assert.ok(names.includes('*') && names.includes('ns'));
});
t('无分号的 export function 不会吞掉后续语句', () => {
  const m = scanModule("export function f(){}\nexport const K = 1;");
  assert.ok(m.exports.some((e) => e.exported === 'K'), 'K 丢失 → stmtEnd 把 {} 当成了语句块');
});
t('import {a} from 的 {} 不被当成语句块', () => {
  const m = scanModule("import { a } from './x.mjs';\nexport const K = 1;");
  assert.equal(m.topLevelStmts.length, 0, `topLevelStmts=${JSON.stringify(m.topLevelStmts)}`);
});
t('动态 import 任意深度都能抓到', () => {
  const m = scanModule(`function f(){ return import('./lazy.mjs'); }`);
  assert.deepEqual(m.dynamicImports, ['./lazy.mjs']);
});
t('import.meta 不算动态导入', () => {
  const m = scanModule(`const u = import.meta.url;`);
  assert.equal(m.dynamicImports.length, 0);
});
t('副作用判定:赋值语句算副作用', () => {
  assert.equal(hasSideEffects(scanModule('globalThis.x = 1;')), true);
  assert.equal(hasSideEffects(scanModule('export const x = 1;')), false);
});

/* ---------- §3 解析器 ---------- */
t('扩展名探测 + 目录 index', () => {
  const fsm = createFS({ 'a/b.mjs': '', 'a/c/index.mjs': '', 'a/d.js': '' });
  assert.equal(resolveSpec(fsm, 'a/main.mjs', './b'), 'a/b.mjs');
  assert.equal(resolveSpec(fsm, 'a/main.mjs', './c'), 'a/c/index.mjs');
  assert.equal(resolveSpec(fsm, 'a/main.mjs', './d'), 'a/d.js');
  assert.equal(resolveSpec(fsm, 'a/main.mjs', './nope'), null);
});
t('.. 路径归一化', () => {
  const fsm = createFS({ 'src/x.mjs': '' });
  assert.equal(resolveSpec(fsm, 'src/deep/main.mjs', '../x.mjs'), 'src/x.mjs');
});

/* ---------- §4 环检测与执行顺序 ---------- */
t('Tarjan 找到 a↔b 环', () => {
  const files = genProject({ modules: 5, withCycles: true });
  files['src/main.mjs'] += `\nimport { a } from './cyc/a.mjs';\nexport const cyc = a(3);`;
  const g = buildGraph(createFS(files), 'src/main.mjs');
  assert.equal(g.cycles.length, 1);
  assert.equal(g.cycles[0].length, 2);
});
t('无环项目:依赖排在依赖者前面', () => {
  const g = buildGraph(createFS(genProject({ modules: 8 })), 'src/main.mjs');
  const pos = new Map(g.order.map((id, i) => [id, i]));
  for (const m of g.modules) {
    for (const d of m.deps) {
      assert.ok(pos.get(d.targetId) < pos.get(m.id), `${m.path} 应在 ${g.modules[d.targetId].path} 之后`);
    }
  }
});
t('自环也能被识别', () => {
  const g = buildGraph(createFS({ 'a.mjs': `import { f } from './a.mjs'; export function f(){}` }), 'a.mjs');
  assert.equal(g.cycles.length, 1);
});

/* ---------- §5 tree-shaking ---------- */
t('未引用的导出被删除', () => {
  const r = build(genProject({ modules: 30, liveRatio: 0.2 }), 'src/main.mjs');
  const code = r.chunks[0].code;
  assert.ok(!/dead_0\b/.test(code), 'dead_0 应被删除');
  assert.ok(/live_0/.test(code), 'live_0 应保留');
});
t('体积确实下降(>=30%)', () => {
  const r = build(genProject({ modules: 60, liveRatio: 0.25 }), 'src/main.mjs');
  const drop = 1 - r.shake.stats.bytesKept / r.shake.stats.bytesTotal;
  assert.ok(drop > 0.3, `降幅仅 ${(drop * 100).toFixed(1)}%`);
});
t('副作用模块不能被摇掉', () => {
  const r = build(genSideEffectProject(), 'src/main.mjs');
  assert.ok(r.chunks[0].code.includes(`push('a')`), '副作用语句被删了');
});

/* ---------- §6 差分对照:原生 ESM vs 打包产物 ---------- */
async function differential(name, files, opts = {}) {
  const dir = await materialize(files, fs, path, os);
  const native = await import(pathToFileURL(path.join(dir, 'src/main.mjs')).href + `?v=${Math.random()}`);
  const r = build(files, 'src/main.mjs', { strategy: 'single', expose: '__ex' });
  const bundled = new Function('__ex', r.chunks[0].code + '\nreturn __ex;')({});
  const keysNative = Object.keys(native).filter((k) => k !== 'default');
  const keysBundle = Object.keys(bundled);
  for (const k of keysNative) {
    const nv = native[k], bv = bundled[k];
    // 函数按引用比较无意义(两个 realm 里是两个对象),改比"零参调用的结果"
    if (typeof nv === 'function') {
      assert.equal(typeof bv, 'function', `导出 ${k} 应为函数`);
      let nr, br, nErr = null, bErr = null;
      try { nr = String(nv()); } catch (e) { nErr = e.constructor.name; }
      try { br = String(bv()); } catch (e) { bErr = e.constructor.name; }
      assert.equal(bErr, nErr, `导出 ${k} 抛错行为不一致`);
      if (!nErr) assert.equal(br, nr, `导出 ${k}() 结果不一致: 打包=${br} 原生=${nr}`);
      continue;
    }
    assert.deepEqual(bv, nv, `导出 ${k} 与原生不一致: 打包=${JSON.stringify(bv)} 原生=${JSON.stringify(nv)}`);
  }
  assert.deepEqual(keysBundle.sort(), keysNative.filter((k) => keysBundle.includes(k)).sort());
  await fs.promises.rm(dir, { recursive: true, force: true });
  void name; void opts;
  return { native, bundled };
}

await ta('差分:常规项目(含影子变量/解构/模板/命名空间/副作用)', async () => {
  await differential('semantic', genSemanticProject());
});
await ta('差分:复导出链(export{} from + export * 穿透两层)', async () => {
  const { native, bundled } = await differential('reexport', genReexportProject());
  assert.equal(bundled.r3, native.r3);
  assert.equal(bundled.r3, 'deep-value');
});
await ta('差分:副作用顺序', async () => {
  const { native, bundled } = await differential('side', genSideEffectProject());
  assert.equal(bundled.order, native.order);
  assert.equal(bundled.order, 'a,b');
});
await ta('差分:合成项目 120 模块', async () => {
  const files = genProject({ modules: 120, liveRatio: 0.3 });
  const dir = await materialize(files, fs, path, os);
  const native = await import(pathToFileURL(path.join(dir, 'src/main.mjs')).href + `?v=${Math.random()}`);
  const r = build(files, 'src/main.mjs', { strategy: 'single', expose: '__ex' });
  const bundled = new Function('__ex', r.chunks[0].code + '\nreturn __ex;')({});
  assert.equal(bundled.total, native.total);
  await fs.promises.rm(dir, { recursive: true, force: true });
});

await ta('差分:默认导出的四种形态(表达式/具名类/具名函数/列表改名)', async () => {
  const { native, bundled } = await differential('defaults', genDefaultFormsProject());
  assert.equal(bundled.d1, 42);
  assert.equal(bundled.d1, native.d1);
  assert.equal(bundled.d2, 6);
  assert.equal(bundled.d4(), 'via-list');
  assert.equal(bundled.d3, 'named-value');
});

t('动态 import 被单独识别出来', () => {
  const files = {
    'src/main.mjs': `export const load = () => import('./lazy.mjs');`,
    'src/lazy.mjs': `export const x = 1;`,
  };
  const r = build(files, 'src/main.mjs');
  assert.deepEqual(r.graph.modules[0].scan.dynamicImports, ['./lazy.mjs']);
});

/* ---------- §7 确定性 ---------- */
t('打乱文件枚举顺序后产物哈希不变', () => {
  const files = genProject({ modules: 40 });
  const a = build(files, 'src/main.mjs', { strategy: 'single' });
  const b = build(shuffleEntries(files, 99), 'src/main.mjs', { strategy: 'single' });
  assert.equal(a.chunks[0].hash, b.chunks[0].hash, '输出不确定 → CI 里缓存会一直失效');
});
t('改一个字节,哈希必变(无碰撞)', () => {
  const files = genProject({ modules: 10 });
  const a = build(files, 'src/main.mjs');
  const files2 = { ...files };
  files2['src/m0.mjs'] = files2['src/m0.mjs'].replace('x + 0', 'x + 1');
  const b = build(files2, 'src/main.mjs');
  assert.notEqual(a.chunks[0].hash, b.chunks[0].hash);
});

/* ---------- §8 分块与缓存失效 ---------- */
t('per-module 策略:改一个模块只失效一个块', () => {
  const files = genProject({ modules: 20, liveRatio: 1 });
  const before = build(files, 'src/main.mjs', { strategy: 'per-module' });
  const files2 = { ...files };
  files2['src/m0.mjs'] = files2['src/m0.mjs'].replace('x + 0', 'x + 1000');
  const after = build(files2, 'src/main.mjs', { strategy: 'per-module' });
  const changed = before.chunks.filter((c, i) => c.hash !== after.chunks[i]?.hash).length;
  assert.equal(changed, 1, `应只失效 1 块,实际 ${changed}`);
});
t('single 策略:改任何模块都让整块失效', () => {
  const files = genProject({ modules: 20, liveRatio: 1 });
  const before = build(files, 'src/main.mjs', { strategy: 'single' });
  const files2 = { ...files };
  files2['src/m0.mjs'] = files2['src/m0.mjs'].replace('x + 0', 'x + 1000');
  const after = build(files2, 'src/main.mjs', { strategy: 'single' });
  assert.notEqual(before.chunks[0].hash, after.chunks[0].hash);
});

/* ---------- §9 哈希本身 ---------- */
t('contentHash 已知值 + 长度', () => {
  assert.equal(contentHash('').length, 16);
  assert.equal(contentHash('abc'), contentHash('abc'));
  assert.notEqual(contentHash('abc'), contentHash('abd'));
});
t('contentHash 逐字符敏感(FNV 双字节混合)', () => {
  assert.notEqual(contentHash('a\u0100'), contentHash('a\u0001'));
});

console.log(`\n${fail === 0 ? '✅' : '❌'} ${pass} 通过 / ${fail} 失败`);
process.exit(fail === 0 ? 0 : 1);

bench.mjs —— 基准主程序(构建耗时/产物体积对比)

// bench.mjs —— 全部数字来源。纯本机实测(Node 22.23.3 / darwin-arm64)
import crypto from 'node:crypto';
import { tokenize, scanModule } from './scan.mjs';
import {
  build, buildGraph, treeShake, link, planChunks, chunkCode,
  contentHash, contentHashFast, createFS,
} from './build.mjs';
import { genProject, shuffleEntries, mulberry32 } from './fixtures.mjs';

const out = [];
const log = (s = '') => { out.push(s); console.log(s); };
const hr = () => Number(process.hrtime.bigint()) / 1e6;

function benchMs(fn, { warm = 3, runs = 9 } = {}) {
  for (let i = 0; i < warm; i++) fn();
  const xs = [];
  for (let i = 0; i < runs; i++) { const t0 = hr(); fn(); xs.push(hr() - t0); }
  xs.sort((a, b) => a - b);
  return xs[Math.floor(xs.length / 2)];
}

const fmtB = (n) => (n >= 1024 * 1024 ? `${(n / 1024 / 1024).toFixed(2)}MB` : n >= 1024 ? `${(n / 1024).toFixed(1)}KB` : `${n}B`);
const pct = (a, b) => (b === 0 ? 'n/a' : `${((a / b) * 100).toFixed(1)}%`);
const mbps = (bytes, ms) => (ms === 0 ? 'inf' : (bytes / 1048576 / (ms / 1000)).toFixed(2));
const sizeOf = (files) => Object.values(files).reduce((a, s) => a + s.length, 0);

log(`# Day35 构建工具 · 实测数据`);
log(`环境: Node ${process.version} / ${process.platform}-${process.arch} / V8 ${process.versions.v8}`);
log('');

/* ================= §1 扫描:真词法器 vs 正则近似 ================= */

log('## §1 扫描吞吐与准确率:真词法器 vs 正则近似');
function regexScan(src) {
  const imports = [];
  const re = /(?:^|\n)\s*import\s+([^'"`]*?)from\s*['"]([^'"]+)['"]/g;
  let m;
  while ((m = re.exec(src))) imports.push(m[2]);
  const din = /import\s*\(\s*['"]([^'"]+)['"]\s*\)/g;
  while ((m = din.exec(src))) imports.push(m[1]);
  return { imports };
}
// 伪装样本:假 import 出现在"行首",正则若无状态就会当真
const tricky = [
  ["字符串里(多行模板)", "const t = `\nimport x from './fake-tpl.mjs'\n`;"],
  ["块注释里", "/*\nimport y from './fake-block.mjs'\n*/"],
  ["正则字面量里", "const re = /\nimport z from '\\/fake-re.mjs'\n/;"],
].map(([label, src]) => [label, src]);
let trickyRegex = 0, trickyTok = 0, trickyTotal = 0;
for (const [label, src] of tricky) {
  const r = regexScan(src).imports.length;
  const t = scanModule(src).imports.length;
  trickyRegex += r; trickyTok += t; trickyTotal++;
  log(`  ${label.padEnd(18)} 正则报 ${r} 条假边,词法器报 ${t} 条`);
}
const realSrc = `import { a } from './a.mjs';\nexport function f(){ return a; }`;
log(`  真实样本           正则 ${regexScan(realSrc).imports.length} 条,词法器 ${scanModule(realSrc).imports.length} 条(都正确)`);
log(`  → 正则的失败模式不是"慢",是"把注释/字符串/正则里的文本当真边",且 ${trickyRegex}/${trickyTotal} 个样本全中`);
log('');

const proj500 = genProject({ modules: 500, liveRatio: 0.3 });
const bytes500 = sizeOf(proj500);
const nMod = Object.keys(proj500).length;
const tTok = benchMs(() => { for (const src of Object.values(proj500)) tokenize(src); });
const tScan = benchMs(() => { for (const src of Object.values(proj500)) scanModule(src); });
const tRegex = benchMs(() => { for (const src of Object.values(proj500)) regexScan(src); });
log(`  500 模块 / ${fmtB(bytes500)}:`);
log(`    tokenize    ${tTok.toFixed(2)}ms  ${mbps(bytes500, tTok)} MB/s  ${(nMod / (tTok / 1000)).toFixed(0)} 模块/s`);
log(`    scanModule  ${tScan.toFixed(2)}ms  ${mbps(bytes500, tScan)} MB/s  ${(nMod / (tScan / 1000)).toFixed(0)} 模块/s`);
log(`    正则近似    ${tRegex.toFixed(2)}ms  ${mbps(bytes500, tRegex)} MB/s  (快 ${(tScan / tRegex).toFixed(1)}x,代价见上)`);
log('');

/* ---- §1b 词法器真正的热路径:字符分类 ---- */
log('## §1b 词法器的热路径:正则 .test() vs charCode 比较');
{
  const N = 2_000_000;
  const sample = 'const abcdefg_123 = foo.bar(baz, 42);'.repeat(Math.ceil(N / 39)).slice(0, N);
  const isIdPartRe = (c) => /[A-Za-z0-9_$]/.test(c);
  const isIdPartCode = (c) => {
    const x = c.charCodeAt(0);
    return (x >= 48 && x <= 57) || (x >= 65 && x <= 90) || (x >= 97 && x <= 122) || x === 95 || x === 36;
  };
  const tRe = benchMs(() => { let k = 0; for (let i = 0; i < sample.length; i++) if (isIdPartRe(sample[i])) k++; return k; });
  const tCode = benchMs(() => { let k = 0; for (let i = 0; i < sample.length; i++) if (isIdPartCode(sample[i])) k++; return k; });
  log(`  ${(N / 1e6).toFixed(0)}M 次字符判断:正则 .test() ${tRe.toFixed(1)}ms,charCode 比较 ${tCode.toFixed(1)}ms → 快 ${(tRe / tCode).toFixed(1)}x`);
  log(`  → 词法器 16 MB/s 的瓶颈就在这里:每个字符都新建一次正则匹配上下文`);
}
log('');

/* ================= §2 模块图规模 ================= */

log('## §2 模块图构建随规模的变化');
const graphRows = [];
for (const n of [200, 1000, 3000]) {
  const files = genProject({ modules: n, liveRatio: 0.3 });
  const bytes = sizeOf(files);
  const ms = benchMs(() => buildGraph(createFS(files), 'src/main.mjs'), { warm: 1, runs: 5 });
  const g = buildGraph(createFS(files), 'src/main.mjs');
  const edges = g.modules.reduce((a, m) => a + m.deps.length, 0);
  graphRows.push({ n, ms });
  log(`  ${String(n).padStart(4)} 模块 / ${fmtB(bytes).padStart(8)}:建图 ${ms.toFixed(1)}ms,边 ${edges},环 ${g.cycles.length}`);
}
const per = graphRows.map((r) => r.ms / r.n);
log(`  单模块摊薄 ${per.map((x) => x.toFixed(3)).join(' / ')} ms → ${per[2] < per[0] * 1.8 ? '近似线性(哈希表 + 单次 DFS)' : '超线性'}`);
log('');

/* ================= §3 tree-shaking 收益 ================= */

log('## §3 tree-shaking:删掉多少');
for (const ratio of [0.1, 0.3, 0.6, 1.0]) {
  const files = genProject({ modules: 300, liveRatio: ratio });
  const r = build(files, 'src/main.mjs', { strategy: 'single' });
  const naive = sizeOf(files);
  const reach = r.graph.modules.length;
  const present = Object.keys(files).filter((k) => k.startsWith('src/m')).length;
  log(`  liveRatio=${ratio.toFixed(1)}  磁盘源码 ${fmtB(naive).padStart(8)} → 产物 ${fmtB(r.chunks[0].code.length).padStart(7)}  降幅 ${(100 - (r.chunks[0].code.length / naive) * 100).toFixed(1)}%  (可达 ${reach}/${present} 模块,未进图 ${present - reach})`);
}
log('');

/* ================= §4 分块策略与缓存失效爆炸半径 ================= */

log('## §4 改一行"活代码",会让多少字节的缓存失效');
const baseFiles = genProject({ modules: 200, liveRatio: 0.7 });
const mutatedFiles = { ...baseFiles };
// 必须改"会输出的活代码":之前误改了 dead_3 里的字面量,产物不变,
// 所有策略都显示 0B 失效 —— 这类"看起来在测其实没测到"的坑最容易被写进文章
mutatedFiles['src/m3.mjs'] = mutatedFiles['src/m3.mjs'].replace('return acc + 3;', 'return acc + 3000;');
if (mutatedFiles['src/m3.mjs'] === baseFiles['src/m3.mjs']) throw new Error('变异没生效');

for (const strategy of ['single', 'vendor', 'shared', 'per-module']) {
  const a = build(baseFiles, 'src/main.mjs', { strategy });
  const b = build(mutatedFiles, 'src/main.mjs', { strategy });
  const total = a.chunks.reduce((x, c) => x + c.bytes, 0);
  const byName = new Map(b.chunks.map((c) => [c.name, c]));
  let invalidated = 0;
  for (const c of a.chunks) {
    const nc = byName.get(c.name);
    if (!nc || nc.hash !== c.hash) invalidated += c.bytes;
  }
  log(`  ${strategy.padEnd(11)} 块数 ${String(a.chunks.length).padStart(3)}  总 ${fmtB(total).padStart(8)}  需重新下载 ${fmtB(invalidated).padStart(8)} = ${pct(invalidated, total)}`);
}
log('');

/* ================= §5 哈希链传播 ================= */

log('## §5 哈希要不要链上依赖?(own-content vs chained)');
{
  const a = build(baseFiles, 'src/main.mjs', { strategy: 'per-module' });
  const b = build(mutatedFiles, 'src/main.mjs', { strategy: 'per-module' });
  let ownBytes = 0;
  for (let i = 0; i < a.chunks.length; i++) if (a.chunks[i].hash !== b.chunks[i]?.hash) ownBytes += a.chunks[i].bytes;

  const gA = buildGraph(createFS(baseFiles), 'src/main.mjs');
  const gB = buildGraph(createFS(mutatedFiles), 'src/main.mjs');
  const chained = (chunks, mods) => {
    const idx = new Map(chunks.map((c) => [Number(c.name.slice(1)), c]));
    const memo = new Map();
    const h = (id) => {
      if (memo.has(id)) return memo.get(id);
      const c = idx.get(id);
      if (!c) return '';
      const deps = [...new Set((mods[id]?.deps ?? []).map((d) => d.targetId).filter((x) => x != null))].sort((x, y) => x - y);
      const v = contentHash(c.code + '|' + deps.map(h).join(''));
      memo.set(id, v);
      return v;
    };
    return h;
  };
  const hA = chained(a.chunks, gA.modules);
  const hB = chained(b.chunks, gB.modules);
  let chainBytes = 0;
  const affected = [];
  for (const c of a.chunks) {
    const id = Number(c.name.slice(1));
    if (hA(id) !== hB(id)) { chainBytes += c.bytes; affected.push(id); }
  }
  const total = a.chunks.reduce((x, c) => x + c.bytes, 0);
  log(`  改 1 个叶子 m3 → 只哈希自身:失效 ${fmtB(ownBytes)} (${pct(ownBytes, total)})`);
  log(`                     链式哈希:失效 ${fmtB(chainBytes)} (${pct(chainBytes, total)}),波及 ${affected.length} 个块`);
  log(`  爆炸半径放大 ${ownBytes ? (chainBytes / ownBytes).toFixed(2) : 'n/a'}x —— 链式哈希买的是"绝不拿到旧产物",代价是缓存命中率`);
}
log('');

/* ================= §6 确定性 ================= */

log('## §6 确定性:枚举顺序会不会改产物');
{
  const files = genProject({ modules: 200, liveRatio: 0.7 });
  const runs = [1, 2, 3, 4, 5].map((seed) => build(shuffleEntries(files, seed), 'src/main.mjs').chunks[0].hash);
  log(`  本实现(拓扑序)打乱 5 次:${new Set(runs).size === 1 ? '哈希完全一致 ✅' : '哈希不一致 ❌'}  ${runs[0]}`);

  // 反例:按"文件枚举顺序"拼(很多手写构建脚本就是这么写的)
  const naive = (files2) => {
    const fsx = createFS(files2);
    const g = buildGraph(fsx, 'src/main.mjs');
    const s = treeShake(g);
    const l = link(g, s);
    // 注意这里用 fsx.keys()(= 调用方给的插入顺序)而不是 g.order
    const order = fsx.keys().map((p) => g.byPath.get(p)?.id).filter((id) => id != null && s.included.has(id));
    return contentHash(order.map((id) => l.pieces.find((p) => p.modId === id)?.code ?? '').join('\n'));
  };
  const nruns = [1, 2, 3, 4, 5].map((seed) => naive(shuffleEntries(files, seed)));
  log(`  反例(按枚举顺序拼):${new Set(nruns).size} 种不同哈希 → 内容没变但文件名全变,CDN 缓存全废`);
}
log('');

/* ================= §7 词法改名的坑:模板插值 ================= */

log('## §7 词法改名:不递归模板插值会坏掉多少');
{
  const rnd = mulberry32(42);
  const N = 400;
  const files = {};
  let withTpl = 0;
  for (let i = 0; i < N; i++) {
    const useTpl = rnd() < 0.5;
    if (useTpl) withTpl++;
    files[`src/m${i}.mjs`] = [
      `export const name_${i} = 'v${i}';`,
      useTpl
        ? `export function fmt_${i}(){ return \`<\${name_${i}}>\`; }`
        : `export function fmt_${i}(){ return '<' + name_${i} + '>'; }`,
    ].join('\n');
  }
  const entry = [];
  for (let i = 0; i < N; i++) entry.push(`import { fmt_${i} } from './m${i}.mjs';`);
  entry.push(`export const all = [${Array.from({ length: N }, (_, i) => `fmt_${i}()`).join(', ')}];`);
  files['src/main.mjs'] = entry.join('\n');

  const r = build(files, 'src/main.mjs', { expose: '__ex' });
  const ok = new Function('__ex', r.chunks[0].code + '\nreturn __ex;')({});
  const good = ok.all.filter((s, i) => s === `<v${i}>`).length;

  const brokenCode = r.chunks[0].code.replace(/\$\{m\d+\$(name_\d+)\}/g, '${$1}');
  let brokenOk = 0, brokenErr = '';
  try {
    const b = new Function('__ex', brokenCode + '\nreturn __ex;')({});
    brokenOk = b.all.filter((s, i) => s === `<v${i}>`).length;
  } catch (e) { brokenOk = -1; brokenErr = e.constructor.name; }

  log(`  ${N} 模块中 ${withTpl} 个用模板插值:正确实现 ${good}/${N} 通过`);
  log(`  不递归插值(\${name} 留原名)→ ${brokenOk === -1 ? `整个产物抛 ${brokenErr}(0/${N})` : `${brokenOk}/${N} 通过`}`);
  log(`  → 一个 40 行函数级别的疏漏,在 ${withTpl} 个模块上同时爆发,且只在运行时暴露`);
}
log('');

/* ================= §8 哈希算法与速度 ================= */

log('## §8 内容哈希:三种实现');
{
  const payloads = Array.from({ length: 200 }, (_, i) => 'x'.repeat(1024) + i);
  const bytes = payloads.reduce((a, p) => a + p.length, 0);
  const tFnvBig = benchMs(() => { for (const p of payloads) contentHash(p); });
  const tFnvFast = benchMs(() => { for (const p of payloads) contentHashFast(p); });
  const tSha = benchMs(() => { for (const p of payloads) crypto.createHash('sha256').update(p).digest('hex'); });
  log(`  ${(bytes / 1024).toFixed(0)}KB / 200 块:`);
  log(`    FNV-1a 64(BigInt 逐字符) ${tFnvBig.toFixed(2)}ms  ${mbps(bytes, tFnvBig)} MB/s`);
  log(`    FNV-1a ×2 32(Math.imul)  ${tFnvFast.toFixed(2)}ms  ${mbps(bytes, tFnvFast)} MB/s  ← 快 ${(tFnvBig / tFnvFast).toFixed(1)}x`);
  log(`    sha256(原生 OpenSSL)      ${tSha.toFixed(2)}ms  ${mbps(bytes, tSha)} MB/s  ← 比 imul 版再快 ${(tFnvFast / tSha).toFixed(1)}x`);
  log(`  → 手写 BigInt 哈希比原生 sha256 慢 ${(tFnvBig / tSha).toFixed(1)}x。"自己写的更快"是错觉`);

  const base = 'a'.repeat(20000);
  const variants = Array.from({ length: 2000 }, (_, i) => base + String.fromCharCode(97 + (i % 26)) + i);
  log(`  2000 个只差末尾的变体:BigInt 版 ${new Set(variants.map(contentHash)).size} 个不同哈希,imul 版 ${new Set(variants.map(contentHashFast)).size} 个`);
}
log('');

/* ================= §9 全链路耗时拆分 ================= */

log('## §9 全链路耗时拆分(3000 模块)');
{
  const files = genProject({ modules: 3000, liveRatio: 0.4 });
  const bytes = sizeOf(files);
  // 3000 模块单次 ~170ms,必须给足 warm 与 runs,否则中位数会被 GC 抖动污染
  const OPT = { warm: 3, runs: 15 };
  const reachable = (() => {
    const g0 = buildGraph(createFS(files), 'src/main.mjs');
    return g0.modules.map((m) => m.source);
  })();
  // 口径对齐:词法基准只跑"可达模块",否则拿全量 3001 个文件和 1201 个比没有意义
  const tLex = benchMs(() => { for (const src of reachable) tokenize(src); }, OPT);
  const tGraph = benchMs(() => buildGraph(createFS(files), 'src/main.mjs'), OPT);
  const g = buildGraph(createFS(files), 'src/main.mjs');
  const tShake = benchMs(() => treeShake(g), OPT);
  const s = treeShake(g);
  const tLink = benchMs(() => link(g, s), OPT);
  const l = link(g, s);
  const tHash = benchMs(() => contentHash(l.code), OPT);
  const total = tGraph + tShake + tLink + tHash;
  log(`  源码 ${fmtB(bytes)} → 产物 ${fmtB(l.stats.bytes)}`);
  log(`  建图(含扫描) ${tGraph.toFixed(1)}ms (${pct(tGraph, total)})  摇树 ${tShake.toFixed(1)}ms (${pct(tShake, total)})  链接 ${tLink.toFixed(1)}ms (${pct(tLink, total)})  哈希 ${tHash.toFixed(2)}ms (${pct(tHash, total)})`);
  log(`  合计 ${total.toFixed(1)}ms;其中纯词法切分 ${tLex.toFixed(1)}ms = ${pct(tLex, total)} 的整条管道`);
  log(`  口径:入口只引用 40% 模块,所以磁盘上 ${Object.keys(files).length} 个文件里只有 ${reachable.length} 个进了图、被解析`);
  log(`  自洽性检查(同一批 ${reachable.length} 个模块):建图 ${tGraph.toFixed(1)}ms >= 纯词法 ${tLex.toFixed(1)}ms ? ${tGraph >= tLex ? '✅ 建图 = 词法 + 解析 + 解析依赖' : '❌ 采样不稳'}`);
  log(`  → 词法/解析是构建的下限;换更快的 parser 才有量级收益`);
}
log('');

/* ---- §9b 链接阶段:一次 O(语句×token) 改 O(token) 的过程 ---- */
log('## §9b 链接阶段:语句×token 双循环 vs 单遍扫描');
{
  const files = genProject({ modules: 3000, liveRatio: 0.4 });
  const g = buildGraph(createFS(files), 'src/main.mjs');
  const sh = treeShake(g);
  const OPT2 = { warm: 2, runs: 11 };
  const tFast = benchMs(() => link(g, sh), OPT2);
  const tNaive = benchMs(() => link(g, sh, { emitter: 'naive' }), OPT2);
  const lF = link(g, sh), lN = link(g, sh, { emitter: 'naive' });
  log(`  单遍扫描(现版本) ${tFast.toFixed(1)}ms`);
  log(`  语句×token 双循环  ${tNaive.toFixed(1)}ms → 慢 ${(tNaive / tFast).toFixed(2)}x`);
  log(`  两者输出一致?${lF.code === lN.code ? '✅ 逐字节相同' : '❌ 不一致(说明优化改变了行为,不可接受)'}`);
  log(`  → 语义完全相同,只因"对每条语句重扫一遍全部 token",就从 ${tFast.toFixed(0)}ms 变成 ${tNaive.toFixed(0)}ms`);
}
log('');

/* ================= §10 增量重建 ================= */

log('## §10 增量:改 1 个文件,全量重扫 vs 扫描缓存');
{
  const N = 3000;
  const files = genProject({ modules: N, liveRatio: 0.4 });
  const cached = new Map();

  const files2 = { ...files };
  files2['src/m3.mjs'] = files2['src/m3.mjs'].replace('return acc + 3;', 'return acc + 3000;');

  // 冷启动:缓存为空 → 全部重扫
  const coldMs = benchMs(() => {
    const c = new Map();
    build(files2, 'src/main.mjs', { cache: c });
  }, { warm: 2, runs: 7 });

  // 增量:缓存预热到"只有 m3 变了"
  const warmMs = benchMs(() => {
    const c = new Map();
    build(files, 'src/main.mjs', { cache: c });   // 预热
    build(files2, 'src/main.mjs', { cache: c });  // 只重扫 m3
  }, { warm: 1, runs: 7 }) - coldMs;

  const coldStat = (() => { const c = new Map(); return build(files2, 'src/main.mjs', { cache: c }).graph.scanStats; })();
  const warmStat = (() => { const c = new Map(); build(files, 'src/main.mjs', { cache: c }); return build(files2, 'src/main.mjs', { cache: c }).graph.scanStats; })();

  log(`  冷启动 ${coldMs.toFixed(0)}ms(扫 ${coldStat.cacheMiss} 个模块,命中 ${coldStat.cacheHit})`);
  log(`  增量    ${warmMs.toFixed(0)}ms(扫 ${warmStat.cacheMiss} 个模块,命中 ${warmStat.cacheHit})`);
  log(`  提速 ${(coldMs / warmMs).toFixed(1)}x —— 但注意剩余时间并没有归零`);
  log(`  剩余 ${warmMs.toFixed(0)}ms 全花在"遍历全图做摇树 + 重新拼接 + 重新哈希"上:模块级缓存能省掉解析,省不掉图遍历`);
  log(`  → 真正要把增量做到 O(变更),得连"链接"也要能增量(Vite 靠 ESM 天然免打包,Turbopack 靠 chunk 级增量)`);
  void N;
}
log('');

/* ================= §11 副作用的代价 ================= */

log('## §11 有副作用的模块:保守判定的代价');
{
  const mk = (withEffect) => {
    const files2 = genProject({ modules: 100, liveRatio: 0.3 });
    if (withEffect) {
      for (const k of Object.keys(files2)) {
        if (k === 'src/main.mjs') continue;
        files2[k] += `\nglobalThis.__t = (globalThis.__t ?? 0) + 1;`;
      }
    }
    return files2;
  };
  const clean = build(mk(false), 'src/main.mjs');
  const dirty = build(mk(true), 'src/main.mjs');
  log(`  干净模块(可整块摇掉)产物 ${fmtB(clean.chunks[0].bytes)}`);
  log(`  每个模块都加一条顶层副作用 → 产物 ${fmtB(dirty.chunks[0].bytes)},放大 ${(dirty.chunks[0].bytes / clean.chunks[0].bytes).toFixed(2)}x`);
  log(`  → 这就是 package.json "sideEffects": false 的真实价值,也是它配错会造成线上故障的原因`);
}
log('');

log('---');
log(`共 ${out.length} 行。以上每个数字均由本文件现场跑出,无手填。`);

extract-and-test.mjs —— 从文章提取代码块并自检

// extract-and-test.mjs —— 把正文里的 ```js 代码块抽出来逐个做语法校验
// 目的:文章里的代码不能是"看起来像代码"的东西。抽出来的每一块都必须能被 Node 解析。
import fs from 'node:fs';
import path from 'node:path';
import os from 'node:os';
import { execFileSync } from 'node:child_process';

// 兼容两种布局:md 与本脚本同目录(开发期),或 md 在上一级(落地后 md 与 day35-assets/ 分开放)
const here = path.dirname(new URL(import.meta.url).pathname);
const candidates = [
  path.join(here, 'Day35-构建工具.md'),
  path.join(here, '..', 'Day35-构建工具.md'),
  process.argv[2],
].filter(Boolean);
const mdPath = candidates.find((p) => fs.existsSync(p));
if (!mdPath) {
  console.error('找不到 Day35-构建工具.md,试过:', candidates.join(', '));
  process.exit(1);
}
const md = fs.readFileSync(mdPath, 'utf8');
console.log(`正文:${mdPath}`);

// 抽 ```js / ```javascript 围栏
const blocks = [];
const re = /```(?:js|javascript)\n([\s\S]*?)```/g;
let m;
while ((m = re.exec(md))) {
  const before = md.slice(0, m.index);
  const line = before.split('\n').length;
  blocks.push({ code: m[1], line });
}

const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'day35-extract-'));
let ok = 0, bad = 0;
blocks.forEach((b, i) => {
  const file = path.join(dir, `block${i}.mjs`);
  fs.writeFileSync(file, b.code, 'utf8');
  try {
    execFileSync(process.execPath, ['--check', file], { stdio: 'pipe' });
    ok++;
    console.log(`✓ block#${i}  (正文第 ${b.line} 行, ${b.code.split('\n').length} 行代码)`);
  } catch (e) {
    bad++;
    console.error(`✗ block#${i}  (正文第 ${b.line} 行)`);
    console.error(String(e.stderr ?? e.message).split('\n').slice(0, 6).join('\n'));
  }
});

fs.rmSync(dir, { recursive: true, force: true });

// 顺手校验正文自报的数字口径
const zh = (md.match(/[\u4e00-\u9fa5]/g) ?? []).length;
const tables = md.split('\n').filter((l) => l.trim().startsWith('|')).length;
console.log('');
console.log(`正文:${zh} 汉字 / ${md.length} 字节 / ${md.split('\n').length} 行 / ${blocks.length} 个 js 代码块 / ${tables} 行表格`);
console.log(`代码块语法校验:${ok} 通过 / ${bad} 失败`);
console.log(zh >= 1500 ? `✅ 正文达到 1500 字要求(${zh})` : `❌ 正文不足 1500 字(${zh})`);
process.exit(bad === 0 && zh >= 1500 ? 0 : 1);