Monorepo

一、技术难点

Day35 讲的是"产物怎么被造出来"------一个仓库、一个入口、一张模块图。这一篇往上走一层:几十个包躺在同一个仓库里。

Monorepo 的地基不是目录结构,也不是 pnpm -r build 这条命令。它是三张表:

  1. 谁依赖谁 ------ 包图、拓扑序、环;
  2. 每个包能看到什么 ------ 依赖解析口径,决定了"能跑"和"合法"之间的距离;
  3. 一次改动会让什么失效 ------ 缓存 key 与受影响集合,决定了 CI 是两分钟还是两小时。

这三张表任何一张错了,症状都不在表里:第一张错了表现成"随机构建失败",第二张错了表现成"本地好好的,发布后崩",第三张错了表现成"改了没生效"或者"没改也全量重跑"。全是只在别人机器上、只在某次发布后出现的现象。

所以我不用任何第三方依赖,从零写了一套最小 monorepo 工具链:graph.mjs(workspace 发现 / 包图 / 拓扑序 / 环检测 / 受影响集合)、deps.mjs(两种解析口径 / 幽灵依赖审计 / 多副本检测 / 版本漂移)、cache.mjs(五种缓存 key 口径 + 内容寻址存储)、tasks.mjs(拓扑编排 + 并发 + 增量)、ci-gate.mjs(把判据做成门禁),配 test-day36.mjs(111 条断言全绿 ,含 6 组与真实子进程 ESM import 的差分对照)和 bench.mjs(12 组实测)。环境:Node 22.23.1 / V8 12.4 / darwin-arm64。

先把结论摆出来,每一条都是这台机器上跑出来的。读之前先说清口径:计数类结论(检出几个、重建几个、错几个)是确定性的 ,重复跑逐字节一致;耗时类数字是单次实测,同一台机器重跑抖动 ±10~20%(文中给了量级,别把小数点当合同)。

流传的说法 实测结果 差在哪
「依赖提升只是 node_modules 长得不一样」 200 包里植入 5 个幽灵依赖:严格口径 5/5 检出 ;而"这 5 个包有没有装"的回答是 5/5 装好了 ,所以"看装没装"的检查 0 检出 提升让未声明的依赖真的被装好 了,npm ls 级别的检查天然看不见
「本地能跑就行」 提供方在场时幽灵 import 5/5 成功 ;把提供方从 node_modules 摘掉后 5/5 立刻崩 (ERR_MODULE_NOT_FOUND) 这类错误只在"那个包恰好不在"时爆:CI 绿、评审绿、发布后炸
「单一实例只要版本一致」 两份物理拷贝:状态 2 vs 1 、跨拷贝 instanceof false ;软链去重后 3 vs 3 、true 单例的判据不是版本号,是模块实例
「用软链就不会有双实例」 realpath 相同 + --preserve-symlinks:状态仍 2 vs 1 模块表按 URL 建,不按 realpath;一个 flag 就能把去重废掉
「缓存 key 把自己文件 hash 一遍就行」 120 包里改 1 个叶子:只重建 1 个,47 个产物是旧的 下游产物内容传递性依赖上游,key 必须链上游产物摘要
「时间戳更快」 git clone 后只改 mtime:全量重建 120 个(100% 浪费);改内容但把 mtime 改回去:0 次重建、48 个产物全错 时间戳既会误报也会漏报,两种错法方向相反
「key 里有版本号就安全」 工具链 0.1.0 → 0.2.0:完整口径 120 个全重建;key 漏掉工具链版本 → 0 次重建、120 个全错 版本号表述的是"依赖变了",不是"编译器变了"
「CI 并发开大就快」 120 包 × 3ms,16 并发:宽图(层深 7)≈10--16x ;深图(层深 10)≈7.7x ;纯链条(层深 119)0.97x ------ 开多少并发都一样 天花板是关键路径,不是并发数
「增量一定快」 200 包全命中 27.12ms,冷构建 45.92ms------本实验构建廉价,只省 41%;构建有真实成本(2ms/包)时全命中 25.25ms vs 冷构建 148.29ms,耗时降到 17.0% 缓存省的是"构建",不是"扫描+算 key";比例取决于你自己的构建有多贵
「有 hash 就够了」 缓存命中不是免费的:200 包全命中的 27.12ms 里,大头是重算 key 要读全部源文件 命中路径也要被当作性能路径来优化(memo 后 key 计算 9.29ms → 7.05ms)
「环检测随便写」 200 包环检测 0.49ms ;有环时 Kahn 只能排 180/200 并报"剩下 20 个" Kahn 说得出"有环",说不出环在哪;报错质量决定修复成本

1.1 难点一:幽灵依赖,提升让它"真的装好了"

npm / yarn / pnpm 的 workspace 会把包链到根 node_modules。于是 packages/a 里写 import x from '@scope/b',即使 a/package.json 里从没声明过 b ,Node 的解析算法也会一路向上找到根 node_modules/@scope/b 并成功。

这不是"配置疏漏",是解析算法本身的工作方式 :Node 从 from 所在目录逐级向上找 node_modules,谁先有就用谁。提升把兄弟包放到了最上层,于是每个包都能看到所有包。

后果是延迟的。我在 200 包里植入 5 个幽灵依赖,实测:

  • 严格口径(只认自己 package.json 声明过的)→ 5/5 检出;
  • naive 口径(Node 真实行为)→ 5/5 解析成功;
  • "这个包在 root/node_modules 里吗"→ 5/5 在,所以这类检查 0 检出;
  • 提供方在场时 import → 5/5 成功 ;摘掉提供方 → 5/5 立刻 ERR_MODULE_NOT_FOUND。

也就是说:这个 bug 的可观测窗口,只在"提供方被删除/重命名/被 --prod 装漏/单独发布"的那一天打开。之前所有绿灯都是真的,只是测的不是这件事。

1.2 难点二:单一实例,判据是模块实例而不是路径

双实例是 monorepo 最经典的事故:两份 React → "Invalid hook call";两份 dayjs → 插件注册失效;两份 @apollo/client → 缓存分裂;两份自己写的 store → 状态各走各的。

但"版本一致"根本不是判据。我在磁盘上铺了两份同名库,用子进程真实 import 测了三种布局:

布局 realpath 相同 模块状态共享 A 侧 B 侧 跨拷贝 instanceof
两份物理拷贝 false false 2 1 false
嵌套副本换成软链(pnpm/workspace) true true 3 3 true
软链 + --preserve-symlinks true false 2 1 false

第三行是全篇最值得记住的一格:realpath 相同,实例依然分裂 。因为模块表是按 URL(resolved specifier)建的,--preserve-symlinks 让 URL 停在软链路径上,于是同一个真实文件被实例化了两次。这也解释了为什么"本地一切正常、CI 只加了一个 flag 就报 hook 错"这类事故真实存在------去重是解析器的行为,不是一个目录结构属性。

顺带一个统计陷阱:软链布局下,目录里有 2 个 state-lib 位置,但真实副本只有 1 份 。任何"数一下 node_modules 里有几份"的检测都必须按 realpath 去重,否则假阳性满天飞。

1.3 难点三:缓存的三个口径都在骗你

缓存 key 是 monorepo 里最容易被"感觉"代替"判断"的地方。我把它们分成四种口径,用同一个 120 包仓库跑同一批改动,把**"重建了几个"和"错了几个"**分开量:

改动 口径 重建数 错误产物数
改叶子内容 完整(自身内容+工具链+依赖区间+上游产物摘要) 48 0
改叶子内容 只 hash 自身文件 1 47 ❌
改叶子内容 时间戳 1 47 ❌
只改 mtime(clone 后) 完整 0 0
只改 mtime(clone 后) 时间戳 120 0(100% 浪费)
改内容但伪造 mtime 完整 48 0
改内容但伪造 mtime 时间戳 0 48 ❌
工具链升级 完整 120 0
工具链升级 完整但漏掉工具链版本 0 120 ❌

("改叶子"的受影响集合是 48/120 ------ 这个仓库里排名第一的底层包,一半的包依赖它。)

这张表里有两件事要分开看:

错法一:少重建。 "只 hash 自身文件"是最常见的自制缓存。它对自己的文件是准的,但忽略了产物内容传递性依赖上游 ------我这里的构建把每个 workspace 依赖的产物摘要内联进 dist,所以下游 dist 一定跟着变。缓存说"没变",产物就真的没变(因为根本没重建),于是发出去的是旧代码,而所有检查都是绿的。

错法二:多重建。 时间戳口径在"内容没变、时间变了"的场合全量重跑(clone、npm ci、git checkout、解压、另一台机器)。实测 120/120,纯浪费。

错法三:漏输入。 工具链版本、tsconfig、NODE_ENV、babel 插件的版本、代码生成器的版本------这些不在 key 里,就等于"换编译器不重建"。实测漏掉工具链版本:0 次重建、120 个产物全错,且没有任何报错。

错法四:时间戳的漏报。 这是最阴的一种:改了内容,但把 mtime 恢复原值(git checkout 在部分文件系统/rsync -t/某些 IDE 保存策略下真的会发生)。时间戳口径 0 次重建、48 个产物全错 。时间戳的问题不是"精度不够",是它用代理指标替代了真正的输入。

1.4 难点四:编排的天花板是关键路径

"CI 慢就把并发调大"是另一个用感觉代替判断的地方。同一台机器、同样 120 个包、每任务 3ms 人造耗时,只改图形状:

并发度 宽图(层深 7,依赖随机取更早的包) 深图(层深 10,只依赖最近 3 个) 纯链条(层深 119,只依赖前一个)
1 716.41ms(1.00x) 511.41ms(1.00x) 600.11ms(1.00x)
2 268.43ms(2.67x) 243.21ms(2.10x) 624.47ms(0.96x)
4 123.51ms(5.80x) 139.33ms(3.67x) 616.54ms(0.97x)
8 76.21ms(9.40x) 98.49ms(5.19x) 587.59ms(1.02x)
16 45.85ms(15.63x) 66.29ms(7.71x) 619.82ms(0.97x)

(宽图/深图的绝对值跨轮抖动明显,我给量级;纯链条那一列几乎不动------因为它测的是物理规律,不是调度器的运气。)

三个数把结论钉死了:

  • 宽图把并发吃满了(15.63x,逼近 16);
  • 深图只有 7.71x,一条 10 层的关键路径压住了天花板;
  • 纯链条 0.97x:并发为 1、2、4、8、16 全都一样慢 。因为每个包都在等前一个包,concurrency 这个旋钮在链状图上完全没有连接。

加速比的上界是 总工作量 / 关键路径长度。这条公式的直接推论:如果你的仓库是"万物依赖 core、core 又依赖 utils",那么 CI 变慢的解法不是加机器,是削关键路径(拆 core、减少互相引用、把工具包做成零依赖)。

(顺带一条测量纪律:这些比值本身抖动很大,我第一版断言写的是"宽图加速比 > 深图加速比 × 1.2",三次连跑里失败过一次------同一次 10.4x vs 8.7x,下一次深图反而更快。计时类断言要用"物理下界"(wall ≥ 关键路径 × 单任务耗时)或"数量级"(链状图 < 2x),不要拿两个抖动值相除。)

1.5 难点五:版本漂移,本地永远发现不了

一个包声明 "@scope/core": "^2.0.0",但它同时是 workspace 内的包,本地版本是 1.0.0。会发生什么?

  • 本地:解析器找到 workspace 的 1.0.0,跑得好好的;
  • 发布:这个包按 ^2.0.0 去 registry 装,是另一个东西。

实测在 200 包里植入 3 处这种漂移:3/3 检出 ,同时这 3 个依赖本地解析 3/3 成功。本地越绿,问题越隐蔽。

workspace: 协议就是为这件事存在的:workspace:^ / workspace:* 的语义是"永不从 registry 解析",与版本号无关。而 workspace:1.2.3 这种钉死写法要求本地版本精确相等,否则应当直接失败------它是一个可判定的断言,不是注释。

1.6 难点六:环,报错质量决定修复成本

Kahn 拓扑排序只能告诉你"排不完"。实测 200 包里植入一个 3 节点环:拓扑序只排出 180/200 ,remaining 有 20 个------这 20 个里有 17 个其实只是"在环的下游",不是环本身。

三色 DFS 才能给出环路径:@mono/pkg-11 → @mono/pkg-150 → @mono/pkg-70 → @mono/pkg-11。成本是 0.18ms(200 包全图)。

代价对比:一次 CI 报"有 20 个包无法排序",工程师平均要花多久找到那个环?报一条路径,是 30 秒。


二、完整解法

下面每一段代码都是 day36-assets/ 里跑过的原文,test-day36.mjs 的 111 条断言覆盖它们。

2.1 第一张表:谁依赖谁

发现要处理 workspace 通配。注意它必须排序------后面所有遍历顺序都建立在这上面:

js 复制代码
export function expandWorkspaces(root, patterns) {
  const out = new Set();
  for (const pat of patterns) {
    if (!pat.includes('*')) {
      const dir = path.join(root, pat);
      if (fs.existsSync(path.join(dir, 'package.json'))) out.add(fs.realpathSync(dir));
      continue;
    }
    const segs = pat.split('/').filter(Boolean);
    const walk = (base, i) => {
      if (i === segs.length) {
        if (fs.existsSync(path.join(base, 'package.json'))) out.add(fs.realpathSync(base));
        return;
      }
      const seg = segs[i];
      if (seg === '*') {
        let entries = [];
        try {
          entries = fs.readdirSync(base, { withFileTypes: true });
        } catch {
          return;
        }
        for (const e of entries) {
          if (!e.isDirectory()) continue;
          if (e.name === 'node_modules' || e.name.startsWith('.')) continue;
          walk(path.join(base, e.name), i + 1);
        }
      } else if (seg === '**') {
        walk(base, i + 1);
        let entries = [];
        try {
          entries = fs.readdirSync(base, { withFileTypes: true });
        } catch {
          return;
        }
        for (const e of entries) {
          if (!e.isDirectory()) continue;
          if (e.name === 'node_modules' || e.name.startsWith('.')) continue;
          walk(path.join(base, e.name), i);
        }
      } else {
        walk(path.join(base, seg), i + 1);
      }
    };
    walk(root, 0);
  }
  return [...out].sort(cmp);
}

建图只认声明的依赖(这是刻意的,src 里真实 import 了什么属于第二张表):

js 复制代码
export function buildGraph(root) {
  const rootPj = JSON.parse(fs.readFileSync(path.join(root, 'package.json'), 'utf8'));
  const dirs = expandWorkspaces(root, rootPj.workspaces || []);
  const packages = new Map();
  for (const d of dirs) {
    const p = readPackage(d);
    packages.set(p.name, p);
  }
  const edges = new Map();
  const reverse = new Map();
  for (const name of packages.keys()) {
    edges.set(name, new Set());
    reverse.set(name, new Set());
  }
  const workspaceEdges = [];
  for (const p of packages.values()) {
    for (const dep of Object.keys(p.deps)) {
      if (packages.has(dep)) {
        if (!edges.get(p.name).has(dep)) workspaceEdges.push([p.name, dep]);
        edges.get(p.name).add(dep);
        reverse.get(dep).add(p.name);
      }
    }
  }
  return { root, rootPj, packages, edges, reverse, workspaceEdges };
}

(下文代码里的 cmp 是 (a, b) => (a < b ? -1 : a > b ? 1 : 0)------全篇所有排序都用它,确定性靠的就是"每个遍历都排序"这一条纪律。)

拓扑序必须是"依赖先出"。这里是第一版踩过的坑:入度用错了方向(按"被依赖数"算),拓扑序整体反了------executed 数量、受影响集合全都对,只有顺序错了。断言 每条依赖边都指向拓扑序更早的位置 在第一次跑测试时就把 97 条违规边全指了出来(一共只有 97 条边,即 100% 违规)。

js 复制代码
export function topoSort(graph) {
  const indeg = new Map();
  for (const n of graph.packages.keys()) indeg.set(n, graph.edges.get(n).size);
  const ready = [...indeg.entries()].filter(([, d]) => d === 0).map(([n]) => n).sort(cmp);
  const order = [];
  while (ready.length) {
    const n = ready.shift();
    order.push(n);
    for (const consumer of [...graph.reverse.get(n)].sort(cmp)) {
      const v = indeg.get(consumer) - 1;
      indeg.set(consumer, v);
      if (v === 0) {
        ready.push(consumer);
        ready.sort(cmp);
      }
    }
  }
  return {
    order,
    remaining: [...indeg.entries()].filter(([, d]) => d > 0).map(([n]) => n).sort(cmp),
  };
}

环检测用三色 DFS,目的是给出路径而不是布尔值:

js 复制代码
export function findCycles(graph) {
  const WHITE = 0, GRAY = 1, BLACK = 2;
  const color = new Map([...graph.packages.keys()].map((n) => [n, WHITE]));
  const stack = [];
  const cycles = [];
  const seen = new Set();

  const visit = (n) => {
    color.set(n, GRAY);
    stack.push(n);
    for (const d of [...graph.edges.get(n)].sort(cmp)) {
      if (color.get(d) === GRAY) {
        const at = stack.indexOf(d);
        const cyc = stack.slice(at);
        const min = [...cyc].sort(cmp)[0];
        const rot = [...cyc.slice(cyc.indexOf(min)), ...cyc.slice(0, cyc.indexOf(min))];
        const key = rot.join('->');
        if (!seen.has(key)) {
          seen.add(key);
          cycles.push([...rot, min]);
        }
      } else if (color.get(d) === WHITE) {
        visit(d);
      }
    }
    stack.pop();
    color.set(n, BLACK);
  };

  for (const n of [...graph.packages.keys()].sort(cmp)) {
    if (color.get(n) === WHITE) visit(n);
  }
  return cycles.sort((a, b) => cmp(a.join(), b.join()));
}

受影响集合是"改动的反向闭包"。它决定增量构建跑多少任务:

js 复制代码
export function affected(graph, changed) {
  const out = new Set(changed);
  const queue = [...changed];
  while (queue.length) {
    const n = queue.shift();
    for (const dep of [...(graph.reverse.get(n) || [])].sort(cmp)) {
      if (!out.has(dep)) {
        out.add(dep);
        queue.push(dep);
      }
    }
  }
  return out;
}

2.2 第二张表:每个包能看到什么

核心是两个口径并排存在:naive 回答"能不能跑",strict 回答"合不合法"。工程上永远用后者做判据、用前者做诊断。

js 复制代码
export function resolveNaive(specifier, fromDir, root) {
  let dir = fromDir;
  const tried = [];
  for (;;) {
    if (path.basename(dir) !== 'node_modules') {
      const cand = path.join(dir, 'node_modules', specifier);
      tried.push(cand);
      const pj = path.join(cand, 'package.json');
      if (fs.existsSync(pj)) {
        let main = './index.js';
        try {
          const meta = JSON.parse(fs.readFileSync(pj, 'utf8'));
          main = typeof meta.exports === 'string' ? meta.exports : meta.main || main;
        } catch {}
        const file = path.join(cand, main);
        return { ok: true, pkgDir: cand, file, realDir: fs.realpathSync(cand), tried };
      }
    }
    const parent = path.dirname(dir);
    if (parent === dir) return { ok: false, tried };
    if (dir === root && parent !== dir) {
      // 继续向上(root 之上还有用户级 node_modules,真实 Node 也会查)
    }
    dir = parent;
  }
}
js 复制代码
export function resolveStrict(pkg, specifier, root) {
  const declared =
    specifier in pkg.deps ||
    specifier in pkg.devDeps ||
    specifier in pkg.peerDeps ||
    specifier in (pkg.optionalDeps || {});
  if (!declared) {
    return { ok: false, reason: 'undeclared', detail: `${pkg.name} 未在 package.json 中声明 ${specifier}` };
  }
  const real = resolveNaive(specifier, pkg.dir, root);
  if (!real.ok) {
    return { ok: false, reason: 'unresolvable', detail: `${specifier} 已声明但磁盘上找不到(装漏了)` };
  }
  return { ok: true, ...real };
}

审计就是把两者对每个裸 specifier 都跑一遍:

js 复制代码
export function auditDependencies(graph) {
  const findings = [];
  let naiveOkButUndeclared = 0;
  let totalSpecs = 0;
  for (const pkg of graph.packages.values()) {
    for (const imp of collectSpecifiers(pkg)) {
      totalSpecs++;
      const naive = resolveNaive(imp.spec, pkg.dir, graph.root);
      const strict = resolveStrict(pkg, imp.spec, graph.root);
      if (naive.ok && !strict.ok) {
        naiveOkButUndeclared++;
        const isWorkspace = graph.packages.has(imp.spec);
        findings.push({
          from: pkg.name,
          spec: imp.spec,
          kind: imp.kind,
          file: imp.file,
          isWorkspacePeer: isWorkspace,
          severity: isWorkspace ? 'high' : 'medium',
        });
      }
    }
  }
  return { findings, naiveOkButUndeclared, totalSpecs };
}

实测成本:200 包 / 578 个源文件 / 106.1KB 源码 → ≈65ms (含读取与解析)。这个数字说明它不是"可选的洁癖",而是每个 PR 都能跑的东西。

关于扫描器本身:这里用的 scanImports 与 Day35 §1.1 同源(判断"我在字符串/注释/模板/正则里吗")。本篇不重复推导,但断言里保留了 10 个语境样本------包括 ${ \import ...` }这种嵌套模板,和export const s='a'; import {y} from 'real'` 这种同行混排。

2.3 单一实例的判定

判定方法不是"数目录",而是真的 import 两次并比较类引用与状态:

js 复制代码
// probe-singleton.mjs ------ 判据是实例,不是路径
import { pathToFileURL } from 'node:url';

const A = await import(pathToFileURL(hoistedEntry).href);
const B = await import(pathToFileURL(nestedEntry).href);

A.inc(1); A.inc(1); B.inc(1);
const t = new A.Token('x');

console.log({
  sharedState: A.read() === B.read(),        // 3/3 才是同一个实例
  sameClassRef: A.Token === B.Token,
  instanceofAcrossCopies: t instanceof B.Token,
});

去重手段按可靠性排序:workspace: 协议(从源头消灭多版本)> 单一 lockfile + 提升 > 软链(依赖解析器行为,可被 --preserve-symlinks 废掉)> resolve.alias / dedupe(构建期打补丁)。

静态统计一定要按 realpath 去重,否则软链布局会给你 2 份假副本;而运行时判定一定要看实例,否则 --preserve-symlinks 下 realpath 相同、实例仍然分裂。

2.4 第三张表:一次改动会让什么失效

完整口径的 key,四个输入缺一不可:

js 复制代码
export function keyFull(pkg, graph, upstreamDigests, toolchain = TOOLCHAIN_VERSION, index = null) {
  const sources = index ? index.sources(pkg) : readSources(pkg);
  const parts = [pkg.name, `tc:${toolchain}`];
  for (const f of sources) parts.push(`${f.rel}:${f.hash}`);
  for (const d of [...graph.edges.get(pkg.name)].sort()) {
    parts.push(`${d}@${pkg.deps[d]}:${graph.packages.get(d).version}:${upstreamDigests.get(d)}`);
  }
  return sha8(parts.join('|'));
}

对应关系:

key 里的输入 防的是哪种事故 漏掉的后果(实测)
自身源文件内容 hash 改了不重建 改内容不生效
工具链版本 换编译器不重建 0 重建 / 120 个全错
依赖声明区间 + 本地版本 锁文件变了不重建 依赖换了但产物是旧的
上游产物摘要 上游内容变了、下游没重建 改 1 个叶子,47 个产物是旧的

上游用产物摘要 而不是上游版本号,是因为"版本没变、内容变了"太常见:本地改一行、patch-package、切分支、workspace: 链接的兄弟包天天在变。版本号只能表述"我期望的版本",不能表述"我实际拿到了什么"。

SourceIndex 解决的是命中路径的成本:key 计算要读全部源文件,memo 之后实测 9.29ms → 7.05ms(每轮 200 次)。注意 memo 命中率是 800/1000------第一轮必然全 miss,所以它优化的是"同一进程内反复取 key",不是冷启动。

js 复制代码
export class SourceIndex {
  constructor() {
    this.memo = new Map();
    this.reads = 0;
    this.hits = 0;
  }
  sources(pkg) {
    const srcDir = path.join(pkg.dir, 'src');
    let names = [];
    try {
      names = fs.readdirSync(srcDir).sort();
    } catch {
      return [];
    }
    const out = [];
    for (const rel of names) {
      if (!/\.(mjs|js|cjs|ts|tsx|json|css)$/.test(rel)) continue;
      const full = path.join(srcDir, rel);
      const st = fs.statSync(full);
      if (!st.isFile()) continue;
      const mk = `${full}|${st.mtimeMs}|${st.size}`;
      const hit = this.memo.get(mk);
      if (hit) {
        this.hits++;
        out.push(hit);
        continue;
      }
      this.reads++;
      const content = fs.readFileSync(full, 'utf8');
      const rec = { rel, content, hash: sha(content), mtimeMs: st.mtimeMs };
      this.memo.set(mk, rec);
      out.push(rec);
    }
    return out;
  }
}

编排器是"就绪集合 + 并发上限"。我第一版在这里写错过一次:启动完任务就检查"是否全部启动 → 结束",于是在 delayMs > 0(任务真的异步)时,最后一个任务的统计被吞掉了------executed 少 1,产物快照少 1。正确条件是全部启动且没有在飞任务:

js 复制代码
// tasks.mjs ------ 调度核心
const pump = () => {
  if (settled) return;
  while (active < concurrency) {
    const next = readyNow()[0];          // 依赖都已完成的包,按名字排序取第一个 => 确定性
    if (!next) break;
    started.add(next);
    active++;
    runOne(next).then(
      () => {
        active--;
        for (const set of remainingDeps.values()) set.delete(next); // 释放下游
        pump();
      },
      (err) => finish(err),
    );
  }
  // 完成 = 全部启动 且 没有在飞任务(只判前者会吞掉最后一批)
  if (started.size === queue.length && active === 0) return finish(null);
  if (active === 0 && readyNow().length === 0) {
    return finish(new Error(`调度死锁:仍有 ${queue.length - started.size} 个任务无法启动`));
  }
};

确定性 是远程缓存能跨机器复用的前提,而它靠"所有遍历都排序":workspace 目录、依赖列表、就绪队列、缓存文件里的条目。实测同一仓库两次冷构建的产物快照逐字节相同,并发 4 与并发 8 也相同(快照摘要 0e7cb983adab7669)。

2.5 版本漂移与 workspace 协议

js 复制代码
export function resolveWorkspaceRange(range, localVersion) {
  if (typeof range !== 'string') return { mode: 'unknown', range, ok: false };
  if (!range.startsWith('workspace:')) return { mode: 'registry', range, ok: true };
  const rest = range.slice('workspace:'.length);
  if (rest === '*' || rest === '^' || rest === '~') return { mode: 'link', range, ok: true };
  const exact = rest.replace(/^[\^~]/, '');
  if (exact === localVersion) return { mode: 'link', range, ok: true };
  return { mode: 'link', range, ok: false, why: `workspace 区间 ${range} 要求 ${exact},本地是 ${localVersion}` };
}
js 复制代码
export function checkVersionSkew(graph) {
  const skews = [];
  for (const pkg of graph.packages.values()) {
    for (const [dep, range] of Object.entries(pkg.deps)) {
      const target = graph.packages.get(dep);
      if (!target) continue;
      const r = resolveWorkspaceRange(range, target.version);
      if (r.mode === 'registry') {
        const m = SEMVER.exec(range);
        if (m) {
          const want = `${m[1]}.${m[2]}.${m[3]}`;
          if (m[1] !== target.version.split('.')[0]) {
            skews.push({ from: pkg.name, dep, range, local: target.version, kind: 'range-vs-workspace' });
          } else if (want !== target.version) {
            skews.push({ from: pkg.name, dep, range, local: target.version, kind: 'range-satisfied-local-differs' });
          }
        }
      } else if (!r.ok) {
        skews.push({ from: pkg.name, dep, range, local: target.version, kind: 'workspace-pin-mismatch' });
      }
    }
  }
  return skews.sort((a, b) => (a.from + a.dep < b.from + b.dep ? -1 : 1));
}

2.6 落成门禁

三条判据 + 两条警告,做成一个能在 PR 里跑的脚本(完整版见 ci-gate.mjs,退出码即结论):

js 复制代码
// ci-gate.mjs ------ 退出码就是结论
const graph = buildGraph(root);
const cycles = findCycles(graph);
const audit = auditDependencies(graph);
const skews = checkVersionSkew(graph);

const blockers = [];
if (cycles.length) blockers.push({ rule: 'no-cycle', detail: cycles.map((c) => c.join(' → ')) });
if (audit.findings.length) {
  blockers.push({
    rule: 'no-phantom-dependency',
    detail: audit.findings.map((f) => `${f.from} 使用未声明的 ${f.spec}`),
  });
}
process.exit(blockers.length ? 1 : 0);

实测在一个植入了 1 个环 + 2 个幽灵依赖 + 1 处漂移的 20 包仓库上:

perl 复制代码
环 2 / 幽灵依赖 2 / 版本漂移 1 / 多副本 0

✗ [BLOCKER] no-cycle
    @mono/pkg-01 → @mono/pkg-08 → @mono/pkg-04 → @mono/pkg-01
✗ [BLOCKER] no-phantom-dependency
    @mono/pkg-06 使用未声明的 @mono/pkg-05
    @mono/pkg-07 使用未声明的 @mono/pkg-06
! [WARN] no-version-skew
    @mono/pkg-09 声明 @mono/pkg-02@^2.0.0,本地 workspace 是 1.0.0

环和幽灵依赖是 blocker (会让运行时炸),漂移和多副本先做 warn(先把存量清了再收紧)------门禁的可用性和严格度是两件事,一上来全 blocker 的团队通常三天后就把门禁注释掉了。

2.7 别在这些地方花时间

实测告诉我哪些优化是白做的:

  • 建图 :400 包 ≈15ms(多次实测 14.7~17.3ms)、拓扑序 ≈2.2ms、每包 ≈0.04ms。包数 8x,成本 6.4x------近似线性且绝对值极小。不要为建图做缓存。
  • 环检测:200 包 0.49ms。
  • 审计 :200 包 65ms,但这是 I/O 受限(读 106KB 源码),能通过"只扫 src/、跳过 .d.ts、按 mtime 增量"再降,但 65ms 已经够便宜了。
  • 真正该盯的是:缓存 key 的输入是否完整 (正确性)、上行产物摘要 (正确性)、关键路径(速度)。前者错了是事故,后者错了只是慢。

三、应用场景

3.1 什么时候该上 monorepo

判据不是"包多",是包之间的耦合是否需要一个原子提交。出现下面任一条,收益通常是正的:

  1. 改 A 必须同步改 B(协议、类型、迁移脚本);
  2. 存在"全仓库一起升级"的动作(Node 版本、TS 版本、lint 规则);
  3. 需要跨包的原子重构(重命名一个公共 API);
  4. 需要统一的可观测性/构建/发布通道。

反过来,如果包之间几乎零耦合、发布节奏完全不同、团队之间不愿共享 CI 配额,那么 monorepo 只是把 10 条独立的流水线合并成 1 条更慢的流水线。这也是为什么 §2.4 那三张表值得先做:它们决定了 monorepo 的收益是不是正的。

3.2 与现成工具的关系

我写的是最小实现,用来把判据讲清楚;生产上应该用现成的,但要知道它落在哪一格里:

能力 pnpm workspace Turborepo Nx 本篇对应
严格依赖可见性 node_modules 非扁平 + workspace: 协议 --- --- §2.2 两个口径
幽灵依赖检测 部分(结构上缓解) --- 有(enforce-module-boundaries) auditDependencies
任务图 / 拓扑编排 -r dependsOn: ["^build"] targetDefaults §2.1 / §2.4
内容寻址缓存 --- 本地 + 远程 本地 + 远程 keyFull
受影响集合 --filter "...[origin/main]" --filter affected affected()
环检测与报路径 构建时报错 报错 报错 findCycles

一句判断标准:换工具换的是"这三张表的实现",不能换掉这三张表本身 。把 Turborepo 的 inputs 配漏一个 glob,等于把 keyFull 里的工具链版本删掉------工具越自动,漏配越安静。

3.3 发布流水线里最容易翻车的三处

  1. 版本漂移(§2.5):发布前必须扫,本地绿不代表发布绿。
  2. 多副本进产物 :dependencies 与 peerDependencies 放错位,构建产物里就会出现两份库。判定方法只有一个------在产物里 import 两次比实例,不要看 lockfile。
  3. 缓存跨分支复用 :key 里必须包含"工具链 + 依赖声明 + 上游产物",否则 feature 分支的产物会污染主干。这也是为什么远程缓存必须按 key 存储、按 key 校验 (verifyIntegrity:读回来要重算 digest,不匹配就重建)。

3.4 与前后篇咬合

  • Day35 构建工具 :本篇的包图就是 Day35 模块图的上层;planChunks 的输入从"一个入口"变成"多个包入口"。Day35 的增量构建天花板(模块级缓存省不掉图遍历)在 monorepo 里被放大成"包级缓存省不掉跨包遍历"。
  • Day37 微前端架构 / Day24 微前端实现 :monorepo 里的每个子应用就是一个包,"谁能看到谁"从架构约定变成可执行断言 (auditDependencies 就是边界检查器)。
  • Day38 流水线:§2.4 的 key + 受影响集合就是 CI 的缓存层;门禁脚本是流水线的门。
  • Day28 SSR/SSG/ISR:服务端/客户端两套图在 monorepo 里会变成两个"环境维度",必须进 key------否则同一份源码的两个产物会互相冒充。
  • Day49 依赖治理 :本篇给的是判据与工具;依赖治理是策略(谁能依赖谁、多久升级一次)。
  • Day34 性能监控:构建耗时、命中率、锥体大小都应该埋点------否则"CI 变慢"永远只能靠猜。

四、总结

Monorepo 不是目录形状,是三张必须自己做对的表:

表 判据 错了的症状 本篇的解
谁依赖谁 拓扑序每条边方向正确 + 环能报出路径 随机构建失败、产物缺依赖 buildGraph / topoSort / findCycles
每个包能看到什么 只认自己 package.json 声明的 本地绿、发布后 ERR_MODULE_NOT_FOUND resolveStrict / auditDependencies
一次改动能省什么 key 链上游产物摘要 + 工具链 + 声明 改了不生效 / 没改全量重跑 keyFull / affected

四句话收尾:

  1. "能跑"与"合法"是两件事。 提升让未声明的依赖真的装好了,所以任何"看装没装"的检查都是 0 检出率;判据只能是"这个包自己声明了吗"。
  2. 单例的判据是模块实例,不是路径,也不是版本。 软链能去重,但 --preserve-symlinks 一句话就能把它废掉------因为模块表按 URL 建。
  3. 缓存 key 的正确性由"漏了什么"决定,不由"有没有 hash"决定。 四种漏法里有两种会让 CI 全绿而产物全错(47/120、120/120),且不报任何错。
  4. 增量的收益取决于锥体,并行的收益取决于关键路径。 这两件事都不在"增量"和"并发"这两个词里,在你的图里------所以先量图。

配套文件(day36-assets/,全部零依赖、可直接 node 跑):

文件 作用
graph.mjs workspace 发现 / 包图 / 拓扑序 / 三色环检测 / 受影响集合
deps.mjs 两种解析口径 / 幽灵依赖审计 / 多副本(按 realpath 去重)/ workspace 协议 / 版本漂移
cache.mjs 五种缓存 key 口径 + SourceIndex + 内容寻址存储
tasks.mjs 拓扑编排 + 并发 + 增量 + 完整性校验
ci-gate.mjs 把判据做成门禁(退出码即结论)
probe-singleton.mjs 子进程真实 import 的单例探针
test-day36.mjs 111 条断言(含 6 组与真实 ESM 的差分对照)
bench.mjs / bench-log.txt 12 组实测与本篇全部数字的来源

附录:完整代码

以下脚本均来自 day36-assets/(完整源码)。另含 bench.mjs(增量构建基准)与两个探针probe-import.mjs / probe-singleton.mjs(单例与导入行为),因篇幅从略,方法同其余脚本。
运行:node test-day36.mjs 验证依赖图/编排/缓存全部断言;node extract-and-test.mjs 提取正文代码块逐段自检。

graph.mjs ------ 包依赖图(拓扑排序、环检测、变更影响面计算)

javascript 复制代码
// graph.mjs --- workspace 发现 + 包图 + 拓扑序 + 环检测 + 受影响集合
// 零依赖。所有输出对输入顺序不敏感(确定性)。
import fs from 'node:fs';
import path from 'node:path';

const cmp = (a, b) => (a < b ? -1 : a > b ? 1 : 0);

/** 极简 workspace 通配支持: packages/* 、 packages 深两层的通配 、 以及具体路径 */
export function expandWorkspaces(root, patterns) {
  const out = new Set();
  for (const pat of patterns) {
    if (!pat.includes('*')) {
      const dir = path.join(root, pat);
      if (fs.existsSync(path.join(dir, 'package.json'))) out.add(fs.realpathSync(dir));
      continue;
    }
    const segs = pat.split('/').filter(Boolean);
    const walk = (base, i) => {
      if (i === segs.length) {
        if (fs.existsSync(path.join(base, 'package.json'))) out.add(fs.realpathSync(base));
        return;
      }
      const seg = segs[i];
      if (seg === '*') {
        let entries = [];
        try {
          entries = fs.readdirSync(base, { withFileTypes: true });
        } catch {
          return;
        }
        for (const e of entries) {
          if (!e.isDirectory()) continue;
          if (e.name === 'node_modules' || e.name.startsWith('.')) continue;
          walk(path.join(base, e.name), i + 1);
        }
      } else if (seg === '**') {
        walk(base, i + 1);
        let entries = [];
        try {
          entries = fs.readdirSync(base, { withFileTypes: true });
        } catch {
          return;
        }
        for (const e of entries) {
          if (!e.isDirectory()) continue;
          if (e.name === 'node_modules' || e.name.startsWith('.')) continue;
          walk(path.join(base, e.name), i);
        }
      } else {
        walk(path.join(base, seg), i + 1);
      }
    };
    walk(root, 0);
  }
  return [...out].sort(cmp);
}

/** 读包,规范化出 { name, version, dir, deps, devDeps, peerDeps, optionalDeps } */
export function readPackage(dir) {
  const pj = JSON.parse(fs.readFileSync(path.join(dir, 'package.json'), 'utf8'));
  return {
    name: pj.name,
    version: pj.version || '0.0.0',
    dir,
    deps: pj.dependencies || {},
    devDeps: pj.devDependencies || {},
    peerDeps: pj.peerDependencies || {},
    optionalDeps: pj.optionalDependencies || {},
    exports: pj.exports,
    main: pj.main,
  };
}

/**
 * 建包图。edge 只来自"声明的依赖里指向 workspace 内包的那些"。
 * 注意:src 里真实 import 了什么,与这里无关 ------ 这个差别就是幽灵依赖的入口。
 */
export function buildGraph(root) {
  const rootPj = JSON.parse(fs.readFileSync(path.join(root, 'package.json'), 'utf8'));
  const dirs = expandWorkspaces(root, rootPj.workspaces || []);
  const packages = new Map();
  for (const d of dirs) {
    const p = readPackage(d);
    packages.set(p.name, p);
  }
  const edges = new Map();
  const reverse = new Map();
  for (const name of packages.keys()) {
    edges.set(name, new Set());
    reverse.set(name, new Set());
  }
  const workspaceEdges = [];
  for (const p of packages.values()) {
    for (const dep of Object.keys(p.deps)) {
      if (packages.has(dep)) {
        if (!edges.get(p.name).has(dep)) workspaceEdges.push([p.name, dep]);
        edges.get(p.name).add(dep);
        reverse.get(dep).add(p.name);
      }
    }
  }
  return { root, rootPj, packages, edges, reverse, workspaceEdges };
}

/**
 * 确定性拓扑序(同级按包名字典序),Kahn。
 * 边 from -> to 的语义是 "from 依赖 to",所以 to 必须更早出现:
 * 入度取"依赖个数",依赖出队后去减少"依赖它的包"的入度。
 */
export function topoSort(graph) {
  const indeg = new Map();
  for (const n of graph.packages.keys()) indeg.set(n, graph.edges.get(n).size);
  const ready = [...indeg.entries()].filter(([, d]) => d === 0).map(([n]) => n).sort(cmp);
  const order = [];
  while (ready.length) {
    const n = ready.shift();
    order.push(n);
    for (const consumer of [...graph.reverse.get(n)].sort(cmp)) {
      const v = indeg.get(consumer) - 1;
      indeg.set(consumer, v);
      if (v === 0) {
        ready.push(consumer);
        ready.sort(cmp);
      }
    }
  }
  return {
    order,
    remaining: [...indeg.entries()].filter(([, d]) => d > 0).map(([n]) => n).sort(cmp),
  };
}

/**
 * 环检测(DFS 三色)。返回所有环的路径(每条环从最小节点名开始,去重)。
 * Kahn 只能告诉你"有没有环",环检测要能指出环在哪一段 ------ 报错信息决定修复成本。
 */
export function findCycles(graph) {
  const WHITE = 0, GRAY = 1, BLACK = 2;
  const color = new Map([...graph.packages.keys()].map((n) => [n, WHITE]));
  const stack = [];
  const cycles = [];
  const seen = new Set();

  const visit = (n) => {
    color.set(n, GRAY);
    stack.push(n);
    for (const d of [...graph.edges.get(n)].sort(cmp)) {
      if (color.get(d) === GRAY) {
        const at = stack.indexOf(d);
        const cyc = stack.slice(at);
        const min = [...cyc].sort(cmp)[0];
        const rot = [...cyc.slice(cyc.indexOf(min)), ...cyc.slice(0, cyc.indexOf(min))];
        const key = rot.join('->');
        if (!seen.has(key)) {
          seen.add(key);
          cycles.push([...rot, min]);
        }
      } else if (color.get(d) === WHITE) {
        visit(d);
      }
    }
    stack.pop();
    color.set(n, BLACK);
  };

  for (const n of [...graph.packages.keys()].sort(cmp)) {
    if (color.get(n) === WHITE) visit(n);
  }
  return cycles.sort((a, b) => cmp(a.join(), b.join()));
}

/** 受影响集合:changed 的自身 + 所有(传递)依赖它的包 */
export function affected(graph, changed) {
  const out = new Set(changed);
  const queue = [...changed];
  while (queue.length) {
    const n = queue.shift();
    for (const dep of [...(graph.reverse.get(n) || [])].sort(cmp)) {
      if (!out.has(dep)) {
        out.add(dep);
        queue.push(dep);
      }
    }
  }
  return out;
}

/** 暴力可达性:用来给 affected 做差分对照 */
export function reachableBruteforce(graph, changed) {
  const out = new Set(changed);
  let grew = true;
  while (grew) {
    grew = false;
    for (const [from, tos] of graph.edges) {
      for (const to of tos) {
        if (out.has(to) && !out.has(from)) {
          out.add(from);
          grew = true;
        }
      }
    }
  }
  return out;
}

/** 包在拓扑序里的"层",用来观察图的形状 */
export function depthOf(graph, order) {
  const depth = new Map();
  for (const n of order) {
    let d = 0;
    for (const dep of graph.edges.get(n)) d = Math.max(d, (depth.get(dep) ?? 0) + 1);
    depth.set(n, d);
  }
  return depth;
}

deps.mjs ------ 依赖版本治理(workspace 协议、hoist、去重、版本漂移检测)

javascript 复制代码
// deps.mjs --- 依赖解析的两个口径、幽灵依赖检测、多副本检测、单例探针
// 零依赖。
import fs from 'node:fs';
import path from 'node:path';

/* ------------------------------------------------------------------ *
 * 1) import 扫描:只保留必要语境判断(回归 Day35 §1.1:判断"我在什么里")
 * ------------------------------------------------------------------ */
export function scanImports(src) {
  const out = [];
  const n = src.length;
  let i = 0;
  let prevSig = '';

  const skipStringFrom = (pos) => {
    const q = src[pos];
    let j = pos + 1;
    while (j < n) {
      const c = src[j];
      if (c === '\\') { j += 2; continue; }
      if (c === q) return j + 1;
      j++;
    }
    return n;
  };

  const skipBraceExpr = (pos) => {
    let depth = 1;
    let j = pos;
    while (j < n && depth > 0) {
      const c = src[j];
      if (c === '{') { depth++; j++; continue; }
      if (c === '}') { depth--; j++; continue; }
      if (c === "'" || c === '"') { j = skipStringFrom(j); continue; }
      if (c === '`') { j = skipTemplateFrom(j); continue; }
      j++;
    }
    return j;
  };

  function skipTemplateFrom(pos) {
    let j = pos + 1;
    while (j < n) {
      const c = src[j];
      if (c === '\\') { j += 2; continue; }
      if (c === '`') return j + 1;
      if (c === '$' && src[j + 1] === '{') { j = skipBraceExpr(j + 2); continue; }
      j++;
    }
    return n;
  }

  const readSpecAt = (at) => {
    while (at < n && /\s/.test(src[at])) at++;
    const q = src[at];
    if (q !== "'" && q !== '"') return null;
    let j = at + 1;
    let s = '';
    while (j < n && src[j] !== q) {
      if (src[j] === '\\') { s += src[j + 1] ?? ''; j += 2; continue; }
      s += src[j];
      j++;
    }
    return { spec: s, end: j + 1 };
  };

  while (i < n) {
    const c = src[i];
    if (c === '/' && src[i + 1] === '/') { while (i < n && src[i] !== '\n') i++; continue; }
    if (c === '/' && src[i + 1] === '*') {
      i += 2;
      while (i < n && !(src[i] === '*' && src[i + 1] === '/')) i++;
      i += 2;
      continue;
    }
    if (c === "'" || c === '"') { i = skipStringFrom(i); prevSig = c; continue; }
    if (c === '`') { i = skipTemplateFrom(i); prevSig = '`'; continue; }
    if (c === '/' && /[(,=:[!&|?{};+\-*%~^]|^$|return|=>/.test(prevSig)) {
      i++;
      let inClass = false;
      while (i < n) {
        const d = src[i];
        if (d === '\\') { i += 2; continue; }
        if (d === '[') inClass = true;
        else if (d === ']') inClass = false;
        else if (d === '/' && !inClass) { i++; break; }
        else if (d === '\n') break;
        i++;
      }
      prevSig = '/';
      continue;
    }

    // import ...
    if (src.startsWith('import', i) && !/[\w$]/.test(src[i + 6] ?? '')) {
      let j = i + 6;
      while (j < n && /\s/.test(src[j])) j++;
      if (src[j] === '(') {
        const s = readSpecAt(j + 1);
        if (s) out.push({ kind: 'dynamic-import', spec: s.spec });
        i = (s ? s.end : j + 1);
        prevSig = '(';
        continue;
      }
      if (src[j] === "'" || src[j] === '"') {
        const s = readSpecAt(j);
        if (s) { out.push({ kind: 'side-effect-import', spec: s.spec }); i = s.end; continue; }
      }
      let k = j;
      let advanced = false;
      let guard = 0;
      while (k < n && guard < 2000) {
        guard++;
        if (src[k] === "'" || src[k] === '"') { k = skipStringFrom(k); continue; }
        if (src[k] === '{') { while (k < n && src[k] !== '}') k++; k++; continue; }
        if (src.startsWith('from', k)) {
          const s = readSpecAt(k + 4);
          if (s) { out.push({ kind: 'static-import', spec: s.spec }); i = s.end; advanced = true; break; }
        }
        if (src[k] === ';') break;
        k++;
      }
      if (!advanced) i = Math.max(k, i + 6);
      prevSig = 't';
      continue;
    }

    // export ... from 'x'
    if (src.startsWith('export', i) && !/[\w$]/.test(src[i + 6] ?? '')) {
      let k = i + 6;
      let advanced = false;
      let guard = 0;
      while (k < n && guard < 2000) {
        guard++;
        if (src[k] === "'" || src[k] === '"') { k = skipStringFrom(k); continue; }
        if (src.startsWith('from', k)) {
          const s = readSpecAt(k + 4);
          if (s) { out.push({ kind: 'export-from', spec: s.spec }); i = s.end; advanced = true; break; }
        }
        if (src[k] === '{') {
          while (k < n && src[k] !== '}') {
            if (src[k] === "'" || src[k] === '"') { k = skipStringFrom(k); continue; }
            k++;
          }
          k++;
          continue;
        }
        if (src[k] === ';') break;
        k++;
      }
      prevSig = 't';
      if (!advanced) i = Math.max(k === i + 6 ? i + 6 : k, i + 6);
      continue;
    }

    if (!/\s/.test(c)) prevSig = c;
    i++;
  }
  return out;
}

export const isBare = (s) => !s.startsWith('.') && !s.startsWith('/') && !s.startsWith('node:') && !s.startsWith('file:');

/* ------------------------------------------------------------------ *
 * 2) 两个解析口径
 * ------------------------------------------------------------------ */

/** 口径 A:Node 的真实行为 ------ 沿目录向上,逐个 node_modules 找 */
export function resolveNaive(specifier, fromDir, root) {
  let dir = fromDir;
  const tried = [];
  for (;;) {
    if (path.basename(dir) !== 'node_modules') {
      const cand = path.join(dir, 'node_modules', specifier);
      tried.push(cand);
      const pj = path.join(cand, 'package.json');
      if (fs.existsSync(pj)) {
        let main = './index.js';
        try {
          const meta = JSON.parse(fs.readFileSync(pj, 'utf8'));
          main = typeof meta.exports === 'string' ? meta.exports : meta.main || main;
        } catch {}
        const file = path.join(cand, main);
        return { ok: true, pkgDir: cand, file, realDir: fs.realpathSync(cand), tried };
      }
    }
    const parent = path.dirname(dir);
    if (parent === dir) return { ok: false, tried };
    if (dir === root && parent !== dir) {
      // 继续向上(root 之上还有用户级 node_modules,真实 Node 也会查)
    }
    dir = parent;
  }
}

/** 口径 B:严格 ------ 只有"自己 package.json 里声明过"的才算合法 */
export function resolveStrict(pkg, specifier, root) {
  const declared =
    specifier in pkg.deps ||
    specifier in pkg.devDeps ||
    specifier in pkg.peerDeps ||
    specifier in (pkg.optionalDeps || {});
  if (!declared) {
    return { ok: false, reason: 'undeclared', detail: `${pkg.name} 未在 package.json 中声明 ${specifier}` };
  }
  const real = resolveNaive(specifier, pkg.dir, root);
  if (!real.ok) {
    return { ok: false, reason: 'unresolvable', detail: `${specifier} 已声明但磁盘上找不到(装漏了)` };
  }
  return { ok: true, ...real };
}

/**
 * 收集一个包里所有 src 文件的裸 specifier。
 * 只扫一层 src 目录里的 .mjs/.js/.ts(fixture 就一层,够用且可解释)。
 */
export function collectSpecifiers(pkg) {
  const out = [];
  const srcDir = path.join(pkg.dir, 'src');
  if (!fs.existsSync(srcDir)) return out;
  for (const f of fs.readdirSync(srcDir).sort()) {
    if (!/\.(mjs|js|cjs|ts|tsx)$/.test(f)) continue;
    const src = fs.readFileSync(path.join(srcDir, f), 'utf8');
    for (const imp of scanImports(src)) {
      if (isBare(imp.spec)) out.push({ file: f, ...imp });
    }
  }
  return out;
}

/**
 * 幽灵依赖全量扫描。
 * naive 口径下"能解析"不代表"合法";strict 口径才是可维护性判据。
 */
export function auditDependencies(graph) {
  const findings = [];
  let naiveOkButUndeclared = 0;
  let totalSpecs = 0;
  for (const pkg of graph.packages.values()) {
    for (const imp of collectSpecifiers(pkg)) {
      totalSpecs++;
      const naive = resolveNaive(imp.spec, pkg.dir, graph.root);
      const strict = resolveStrict(pkg, imp.spec, graph.root);
      if (naive.ok && !strict.ok) {
        naiveOkButUndeclared++;
        const isWorkspace = graph.packages.has(imp.spec);
        findings.push({
          from: pkg.name,
          spec: imp.spec,
          kind: imp.kind,
          file: imp.file,
          isWorkspacePeer: isWorkspace,
          severity: isWorkspace ? 'high' : 'medium',
        });
      }
    }
  }
  return { findings, naiveOkButUndeclared, totalSpecs };
}

/* ------------------------------------------------------------------ *
 * 3) 多副本:同一包名在磁盘上有几个物理拷贝
 * ------------------------------------------------------------------ */
export function findPhysicalCopies(root, names) {
  const copies = new Map([...names].map((n) => [n, []]));
  const walk = (dir, depth) => {
    if (depth > 6) return;
    let entries = [];
    try {
      entries = fs.readdirSync(dir, { withFileTypes: true });
    } catch {
      return;
    }
    for (const e of entries) {
      if (!e.isDirectory()) continue;
      const full = path.join(dir, e.name);
      if (e.name === 'node_modules') {
        for (const name of names) {
          const cand = path.join(full, name);
          if (fs.existsSync(path.join(cand, 'package.json'))) {
            copies.get(name).push({ at: cand, real: fs.realpathSync(cand) });
          }
        }
        walk(full, depth + 1);
        continue;
      }
      if (e.name.startsWith('.') || e.name === 'dist') continue;
      // 不跟随软链,避免重复计数
      walk(full, depth + 1);
    }
  };
  walk(root, 0);
  return copies;
}

/** 在磁盘上铺一个"有状态库"的两份物理拷贝,用于单例探针 */
export function setupSingletonCase(root, name = 'state-lib') {
  const lib = `let n = 0;\nexport class Token { constructor(v){ this.v = v; } }\nexport const inc = (k = 1) => (n += k);\nexport const read = () => n;\nexport const id = () => 'copy-' + (globalThis.__seen = (globalThis.__seen || 0) + 1);\n`;
  const mk = (dir) => {
    fs.mkdirSync(path.join(dir, 'src'), { recursive: true });
    fs.writeFileSync(path.join(dir, 'src', 'index.mjs'), lib);
    fs.writeFileSync(
      path.join(dir, 'package.json'),
      JSON.stringify({ name, version: '1.0.0', type: 'module', exports: { '.': './src/index.mjs' } }, null, 2) + '\n',
    );
    return dir;
  };
  const hoisted = mk(path.join(root, 'node_modules', name));
  const nested = mk(path.join(root, 'packages', 'pkg-00', 'node_modules', name));
  return { name, hoisted, nested };
}

/* ------------------------------------------------------------------ *
 * 4) workspace 协议与版本漂移
 * ------------------------------------------------------------------ */

const SEMVER = /^\^?(\d+)\.(\d+)\.(\d+)/;

/**
 * workspace 协议的含义是"永不从 registry 解析",与版本号无关。
 * 'workspace:^' / 'workspace:*' / 'workspace:~' => 直接链本地;
 * 'workspace:1.2.3' => 要求本地版本精确等于该值,否则发布/安装会炸。
 */
export function resolveWorkspaceRange(range, localVersion) {
  if (typeof range !== 'string') return { mode: 'unknown', range, ok: false };
  if (!range.startsWith('workspace:')) return { mode: 'registry', range, ok: true };
  const rest = range.slice('workspace:'.length);
  if (rest === '*' || rest === '^' || rest === '~') return { mode: 'link', range, ok: true };
  const exact = rest.replace(/^[\^~]/, '');
  if (exact === localVersion) return { mode: 'link', range, ok: true };
  return { mode: 'link', range, ok: false, why: `workspace 区间 ${range} 要求 ${exact},本地是 ${localVersion}` };
}

/**
 * 版本漂移:声明的是 registry 区间,但它同时是 workspace 内的包。
 * 这类依赖会被解析到"提升上来的那份",版本与声明不符时两种世界:
 * 本地装的是 workspace 版本(能跑),发布出去的包却按 registry 区间装(可能是另一个版本)。
 */
export function checkVersionSkew(graph) {
  const skews = [];
  for (const pkg of graph.packages.values()) {
    for (const [dep, range] of Object.entries(pkg.deps)) {
      const target = graph.packages.get(dep);
      if (!target) continue;
      const r = resolveWorkspaceRange(range, target.version);
      if (r.mode === 'registry') {
        const m = SEMVER.exec(range);
        if (m) {
          const want = `${m[1]}.${m[2]}.${m[3]}`;
          if (m[1] !== target.version.split('.')[0]) {
            skews.push({ from: pkg.name, dep, range, local: target.version, kind: 'range-vs-workspace' });
          } else if (want !== target.version) {
            skews.push({ from: pkg.name, dep, range, local: target.version, kind: 'range-satisfied-local-differs' });
          }
        }
      } else if (!r.ok) {
        skews.push({ from: pkg.name, dep, range, local: target.version, kind: 'workspace-pin-mismatch' });
      }
    }
  }
  return skews.sort((a, b) => (a.from + a.dep < b.from + b.dep ? -1 : 1));
}

tasks.mjs ------ 任务编排(并发调度、依赖感知、增量执行)

javascript 复制代码
// tasks.mjs --- 任务编排:拓扑序 + 并发 + 三种缓存口径的执行器
// 零依赖。
import path from 'node:path';
import fs from 'node:fs';
import { topoSort, affected } from './graph.mjs';
import { buildOne, keyMtime, keySrcOnly, keyFull, keyFullNoToolchain, CacheStore, TOOLCHAIN_VERSION, sha } from './cache.mjs';

const distFileOf = (pkg) => path.join(pkg.dir, 'dist', 'index.mjs');

/** 从磁盘读产物;没有就返回 null */
export function readDiskDist(graph, name) {
  const f = distFileOf(graph.packages.get(name));
  return fs.existsSync(f) ? fs.readFileSync(f, 'utf8') : null;
}

/**
 * 执行一次构建。
 * opt: { scheme, cache, concurrency, changed, toolchain, verifyIntegrity }
 * scheme: 'nocache' | 'mtime' | 'srcOnly' | 'full'
 */
export async function runPipeline(graph, opt = {}) {
  const {
    scheme = 'full',
    cache = new CacheStore(path.join(graph.root, '.mono-cache.json')),
    concurrency = 4,
    changed = null,
    toolchain = TOOLCHAIN_VERSION,
    verifyIntegrity = true,
    delayMs = 0,
  } = opt;

  const { order, remaining } = topoSort(graph);
  if (remaining.length) throw new Error(`图里有环,无法编排:${remaining.slice(0, 4).join(', ')}`);

  const scope = changed ? affected(graph, changed) : new Set(graph.packages.keys());
  const queue = order.filter((n) => scope.has(n));

  // 作用域外的依赖:产物从磁盘来(增量构建的真实前提:它上次已经建过)
  const distOf = new Map();
  const outputShas = new Map();
  for (const n of order) {
    if (scope.has(n)) continue;
    const code = readDiskDist(graph, n);
    if (code == null) throw new Error(`增量失效:作用域外的 ${n} 没有磁盘产物,必须先全量构建`);
    distOf.set(n, code);
    outputShas.set(n, sha(code));
  }

  const remainingDeps = new Map();
  for (const n of queue) {
    remainingDeps.set(n, new Set([...graph.edges.get(n)].filter((d) => scope.has(d))));
  }

  const getDepDist = (d) => {
    if (distOf.has(d)) return distOf.get(d);
    const code = readDiskDist(graph, d);
    if (code == null) throw new Error(`缓存链断裂:${d} 没有产物`);
    return code;
  };

  let executed = 0;
  let hits = 0;
  let buildMs = 0;
  const executedNames = [];
  const hitNames = [];
  const t0 = performance.now();

  const keyOf = (pkg) => {
    if (scheme === 'mtime') return keyMtime(pkg, graph);
    if (scheme === 'srcOnly') return keySrcOnly(pkg, graph);
    if (scheme === 'full') return keyFull(pkg, graph, outputShas, toolchain);
    if (scheme === 'fullNoToolchain') return keyFullNoToolchain(pkg, graph, outputShas);
    return null;
  };

  const runOne = async (name) => {
    const pkg = graph.packages.get(name);
    const key = keyOf(pkg);

    if (scheme !== 'nocache' && key) {
      const hit = cache.get(key);
      if (hit && typeof hit.digest === 'string') {
        let code = hit.code ?? null;
        if (verifyIntegrity) {
          const onDisk = readDiskDist(graph, name);
          if (onDisk != null && sha(onDisk) === hit.digest) code = onDisk;
          else if (code == null) code = null;
        }
        if (code != null) {
          fs.mkdirSync(path.dirname(distFileOf(pkg)), { recursive: true });
          fs.writeFileSync(distFileOf(pkg), code);
          distOf.set(name, code);
          outputShas.set(name, hit.digest);
          hits++;
          hitNames.push(name);
          return;
        }
      }
    }

    if (delayMs > 0) await new Promise((r) => setTimeout(r, delayMs));
    const bt = performance.now();
    const built = buildOne(pkg, graph, { getDepDist, toolchain });
    buildMs += performance.now() - bt;
    executed++;
    executedNames.push(name);
    distOf.set(name, built.code);
    outputShas.set(name, built.digest);
    if (scheme !== 'nocache' && key) cache.set(key, { digest: built.digest, code: built.code });
  };

  // ---- 调度:就绪集合 + 并发上限 ----
  const started = new Set();
  const readyNow = () => queue.filter((n) => !started.has(n) && remainingDeps.get(n).size === 0);
  let active = 0;

  await new Promise((resolve, reject) => {
    let settled = false;
    const finish = (err) => {
      if (settled) return;
      settled = true;
      err ? reject(err) : resolve();
    };

    const pump = () => {
      if (settled) return;
      while (active < concurrency) {
        const next = readyNow()[0];
        if (!next) break;
        started.add(next);
        active++;
        runOne(next).then(
          () => {
            active--;
            for (const set of remainingDeps.values()) set.delete(next);
            pump();
          },
          (err) => finish(err),
        );
      }
      // 完成条件:全部已启动 且 没有在飞的任务
      if (started.size === queue.length && active === 0) return finish(null);
      if (active === 0 && readyNow().length === 0) {
        return finish(new Error(`调度死锁:仍有 ${queue.length - started.size} 个任务无法启动`));
      }
    };
    pump();
  });

  return {
    scheme,
    concurrency,
    scope: [...scope].sort(),
    outputShas,
    executed,
    hits,
    executedNames: executedNames.sort(),
    hitNames: hitNames.sort(),
    buildMs: Number(buildMs.toFixed(2)),
    wallMs: Number((performance.now() - t0).toFixed(2)),
    order,
  };
}

/** 干净重建(无缓存)作为 ground truth */
export async function cleanRebuild(graph, concurrency = 4) {
  return runPipeline(graph, { scheme: 'nocache', concurrency });
}

/** 对比两组产物摘要,返回 stale(内容与 ground truth 不同)的包名 */
export function diffOutputs(a, b) {
  const stale = [];
  for (const [name, digest] of a) {
    if (b.get(name) !== digest) stale.push(name);
  }
  return stale.sort();
}

cache.mjs ------ 构建缓存(内容寻址、命中率、失效策略)

javascript 复制代码
// cache.mjs --- 构建缓存:三种 key 口径 + 一个确定性的构建函数
// 零依赖。
import fs from 'node:fs';
import path from 'node:path';
import crypto from 'node:crypto';

export const TOOLCHAIN_VERSION = 'mono-build/0.1.0';

export const sha = (s) => crypto.createHash('sha256').update(s).digest('hex');
export const sha8 = (s) => sha(s).slice(0, 16);

/** 读一个包 src 下所有文件的 [{rel, content, hash, mtimeMs}] */
export function readSources(pkg) {
  const srcDir = path.join(pkg.dir, 'src');
  const out = [];
  if (!fs.existsSync(srcDir)) return out;
  for (const rel of fs.readdirSync(srcDir).sort()) {
    const full = path.join(srcDir, rel);
    const st = fs.statSync(full);
    if (!st.isFile()) continue;
    if (!/\.(mjs|js|cjs|ts|tsx|json|css)$/.test(rel)) continue;
    const content = fs.readFileSync(full, 'utf8');
    out.push({ rel, content, hash: sha(content), mtimeMs: st.mtimeMs });
  }
  return out;
}

const stripImports = (src) => src.replace(/^\s*import\s+[^;]*?from\s*['"][^'"]+['"];?\s*$/gm, '').replace(/^\s*import\s*['"][^'"]+['"];?\s*$/gm, '');

/** 从 import 子句里抽出本地名:[{spec, locals}] */
function importBindings(src) {
  const out = [];
  for (const m of src.matchAll(/^\s*import\s*\{([^}]*)\}\s*from\s*['"]([^'"]+)['"];?\s*$/gm)) {
    const locals = m[1]
      .split(',')
      .map((s) => s.trim())
      .filter(Boolean)
      .map((s) => (s.includes(' as ') ? s.split(' as ')[1].trim() : s.trim()));
    out.push({ spec: m[2], locals });
  }
  return out;
}

/**
 * 确定性构建:把包自己的 src 转成 dist,并把每个 workspace 依赖的"产物摘要"内联进去。
 * 关键性质:产物内容传递性地依赖上游产物摘要 ------ 这就是下游缓存必须链上游的物理原因。
 * 内联摘要而不是内联全文:bundler 内联全文会让产物随图深度指数膨胀,
 * 而"内容依赖"这件事只需要摘要就能表达完整。
 */
export function buildOne(pkg, graph, ctx = {}) {
  const sources = readSources(pkg);
  const parts = [`// built: ${pkg.name}@${pkg.version}`, `// toolchain: ${ctx.toolchain ?? TOOLCHAIN_VERSION}`];
  const depShims = [];
  for (const depName of [...graph.edges.get(pkg.name)].sort()) {
    const depDist = ctx.getDepDist ? ctx.getDepDist(depName) : ctx.distOf?.(depName);
    if (depDist == null) throw new Error(`缓存链断裂:${pkg.name} 依赖的 ${depName} 还没有产物`);
    const digest = ctx.digestOf ? ctx.digestOf(depName) ?? sha(depDist) : sha(depDist);
    parts.push(`// ---- inlined from ${depName} @ ${digest.slice(0, 16)} ----`, `export const __snapshot_${depName.replace(/[^a-zA-Z0-9]/g, '_')} = '${digest}';`);
  }
  for (const f of sources) {
    const own = stripImports(f.content);
    for (const b of importBindings(f.content)) {
      const depName = b.spec;
      if (!graph.edges.get(pkg.name).has(depName)) continue;
      const digest = ctx.digestOf ? ctx.digestOf(depName) : sha(ctx.getDepDist ? ctx.getDepDist(depName) : ctx.distOf.get(depName));
      for (const local of b.locals) depShims.push(`const ${local} = '${digest}';`);
    }
    parts.push(`// ---- own: ${f.rel} ----`);
    if (!depShims.length) parts.push(own);
    else parts.push(depShims.join('\n'), own);
  }
  const code = parts.join('\n') + '\n';
  const distDir = path.join(pkg.dir, 'dist');
  fs.mkdirSync(distDir, { recursive: true });
  const outFile = path.join(distDir, 'index.mjs');
  fs.writeFileSync(outFile, code);
  return { code, digest: sha(code), outFile, bytes: Buffer.byteLength(code) };
}

/**
 * 源文件索引:key 计算最容易变成"每次都比构建还慢"的阶段,
 * 因为它要读全部源文件。用 (mtime, size) 做 memo,命中就不读内容。
 */
export class SourceIndex {
  constructor() {
    this.memo = new Map();
    this.reads = 0;
    this.hits = 0;
  }
  sources(pkg) {
    const srcDir = path.join(pkg.dir, 'src');
    let names = [];
    try {
      names = fs.readdirSync(srcDir).sort();
    } catch {
      return [];
    }
    const out = [];
    for (const rel of names) {
      if (!/\.(mjs|js|cjs|ts|tsx|json|css)$/.test(rel)) continue;
      const full = path.join(srcDir, rel);
      const st = fs.statSync(full);
      if (!st.isFile()) continue;
      const mk = `${full}|${st.mtimeMs}|${st.size}`;
      const hit = this.memo.get(mk);
      if (hit) {
        this.hits++;
        out.push(hit);
        continue;
      }
      this.reads++;
      const content = fs.readFileSync(full, 'utf8');
      const rec = { rel, content, hash: sha(content), mtimeMs: st.mtimeMs };
      this.memo.set(mk, rec);
      out.push(rec);
    }
    return out;
  }
}

/* ------------------------------------------------------------------ *
 * 三种缓存 key 口径
 * ------------------------------------------------------------------ */

/** A. 时间戳口径(make / 早期 tsc -incremental 的做法) */
export function keyMtime(pkg, graph) {
  const sources = readSources(pkg);
  const parts = [pkg.name, TOOLCHAIN_VERSION];
  for (const f of sources) parts.push(`${f.rel}:${Math.round(f.mtimeMs)}`);
  for (const d of [...graph.edges.get(pkg.name)].sort()) {
    parts.push(`${d}@${graph.packages.get(d).version}`);
  }
  return sha8(parts.join('|'));
}

/** B. 自身内容口径 ------ "我把自己 src 里的文件 hash 一遍就够了" */
export function keySrcOnly(pkg, graph) {
  const sources = readSources(pkg);
  const parts = [pkg.name, TOOLCHAIN_VERSION];
  for (const f of sources) parts.push(`${f.rel}:${f.hash}`);
  for (const d of [...graph.edges.get(pkg.name)].sort()) {
    parts.push(`${d}@${graph.packages.get(d).version}`);
  }
  return sha8(parts.join('|'));
}

/**
 * C. 完整口径 ------ 自身内容 + 工具链 + 依赖声明区间 + 上游产物摘要。
 * 上游用"产物摘要"而不是"上游版本号":版本号不变而内容变了(本地改 / patch / 换分支)时,
 * 版本号口径会静默命中旧缓存。
 */
export function keyFull(pkg, graph, upstreamDigests, toolchain = TOOLCHAIN_VERSION, index = null) {
  const sources = index ? index.sources(pkg) : readSources(pkg);
  const parts = [pkg.name, `tc:${toolchain}`];
  for (const f of sources) parts.push(`${f.rel}:${f.hash}`);
  for (const d of [...graph.edges.get(pkg.name)].sort()) {
    parts.push(`${d}@${pkg.deps[d]}:${graph.packages.get(d).version}:${upstreamDigests.get(d)}`);
  }
  return sha8(parts.join('|'));
}

/** C'. 完整口径但漏掉工具链版本 ------ 最常见的"换编译器不重建"事故 */
export function keyFullNoToolchain(pkg, graph, upstreamDigests) {
  const sources = readSources(pkg);
  const parts = [pkg.name];
  for (const f of sources) parts.push(`${f.rel}:${f.hash}`);
  for (const d of [...graph.edges.get(pkg.name)].sort()) {
    parts.push(`${d}@${pkg.deps[d]}:${graph.packages.get(d).version}:${upstreamDigests.get(d)}`);
  }
  return sha8(parts.join('|'));
}

/* ------------------------------------------------------------------ *
 * 缓存存储:key -> { digest, file }
 * ------------------------------------------------------------------ */
export class CacheStore {
  constructor(file) {
    this.file = file;
    this.map = new Map();
    if (fs.existsSync(file)) {
      try {
        const raw = JSON.parse(fs.readFileSync(file, 'utf8'));
        for (const [k, v] of Object.entries(raw)) this.map.set(k, v);
      } catch {}
    }
  }
  get(k) {
    return this.map.get(k);
  }
  set(k, v) {
    this.map.set(k, v);
  }
  save() {
    fs.writeFileSync(this.file, JSON.stringify(Object.fromEntries([...this.map].sort((a, b) => (a[0] < b[0] ? -1 : 1))), null, 0));
  }
  size() {
    return this.map.size;
  }
  clear() {
    this.map.clear();
  }
}

ci-gate.mjs ------ CI 门禁(只构建受影响包、全仓库门禁)

javascript 复制代码
#!/usr/bin/env node
// ci-gate.mjs --- 把 Day36 的三条判据做成 CI 门禁
// 用法: node ci-gate.mjs <monorepo-root> [--json]
// 退出码: 0 = 通过, 1 = 有阻塞问题, 2 = 用法错误
import fs from 'node:fs';
import path from 'node:path';
import { buildGraph, topoSort, findCycles } from './graph.mjs';
import { auditDependencies, checkVersionSkew, findPhysicalCopies } from './deps.mjs';

const args = process.argv.slice(2).filter((a) => !a.startsWith('--'));
const asJson = process.argv.includes('--json');
const root = args[0] && path.resolve(args[0]);

if (!root || !fs.existsSync(path.join(root, 'package.json'))) {
  console.error('用法: node ci-gate.mjs <monorepo-root> [--json]');
  process.exit(2);
}

const graph = buildGraph(root);
const cycles = findCycles(graph);
const { order, remaining } = topoSort(graph);
const audit = auditDependencies(graph);
const skews = checkVersionSkew(graph);

// 同一包名的多个物理副本(按 realpath 去重后才算真副本)
const names = new Set([...graph.packages.values()].map((p) => p.name));
const copies = findPhysicalCopies(root, names);
const dupes = [];
for (const [name, list] of copies) {
  const real = new Set(list.map((c) => c.real));
  if (real.size > 1) dupes.push({ name, copies: real.size, at: [...real] });
}

const blockers = [];
const warnings = [];

if (cycles.length) blockers.push({ rule: 'no-cycle', detail: cycles.map((c) => c.join(' → ')) });
if (remaining.length) {
  warnings.push({ rule: 'topo-complete', detail: [`有 ${remaining.length} 个包无法排序:${remaining.slice(0, 6).join(', ')}`] });
}
if (audit.findings.length) {
  blockers.push({
    rule: 'no-phantom-dependency',
    detail: audit.findings.map((f) => `${f.from} 使用未声明的 ${f.spec}`),
  });
}
for (const s of skews) {
  (s.kind === 'workspace-pin-mismatch' ? blockers : warnings).push({
    rule: 'no-version-skew',
    detail: [`${s.from} 声明 ${s.dep}@${s.range},本地 workspace 是 ${s.local}`],
  });
}
if (dupes.length) {
  warnings.push({ rule: 'single-instance', detail: dupes.map((d) => `${d.name} 有 ${d.copies} 份物理副本`) });
}

const report = {
  root,
  packages: graph.packages.size,
  edges: graph.workspaceEdges.length,
  order: order.length,
  findings: {
    cycles: cycles.length,
    phantoms: audit.findings.length,
    skews: skews.length,
    duplicates: dupes.length,
  },
  blockers,
  warnings,
};

if (asJson) {
  console.log(JSON.stringify(report, null, 2));
} else {
  console.log(`仓库 ${root}`);
  console.log(`包 ${report.packages} / 依赖边 ${report.edges} / 可排序 ${report.order}`);
  console.log(`环 ${report.findings.cycles} / 幽灵依赖 ${report.findings.phantoms} / 版本漂移 ${report.findings.skews} / 多副本 ${report.findings.duplicates}`);
  for (const b of blockers) {
    console.log(`\n✗ [BLOCKER] ${b.rule}`);
    for (const d of b.detail.slice(0, 10)) console.log(`    ${d}`);
  }
  for (const w of warnings) {
    console.log(`\n! [WARN] ${w.rule}`);
    for (const d of w.detail.slice(0, 10)) console.log(`    ${d}`);
  }
  console.log(blockers.length ? `\n门禁失败:${blockers.reduce((s, b) => s + b.detail.length, 0)} 个阻塞项` : '\n✓ 门禁通过');
}

process.exit(blockers.length ? 1 : 0);

fixtures.mjs ------ 测试夹具(多包工作区场景)

javascript 复制代码
// fixtures.mjs --- 确定性生成一个真实落在磁盘上的 monorepo
// 零依赖。所有参数由 seed 决定,同一 seed 两次生成逐字节相同。
import fs from 'node:fs';
import path from 'node:path';

export function mulberry32(seed) {
  let a = seed >>> 0;
  return function () {
    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;
  };
}

export function rmrf(p) {
  fs.rmSync(p, { recursive: true, force: true });
}

const pad = (n, w = 2) => String(n).padStart(w, '0');

/**
 * 生成 N 个 workspace 包,保证依赖边只指向序号更小的包 => 天生是 DAG。
 * @param {string} root 目标目录
 * @param {object} opt { count, seed, fanout, linesPerFile, clean }
 */
export function makeRepo(root, opt = {}) {
  const { count = 60, seed = 42, fanout = 0.28, linesPerFile = 12, clean = true, window = 0 } = opt;
  if (clean) rmrf(root);
  fs.mkdirSync(path.join(root, 'packages'), { recursive: true });
  const rnd = mulberry32(seed);

  const pkgs = [];
  for (let i = 0; i < count; i++) {
    const name = `@mono/pkg-${pad(i)}`;
    const deps = [];
    // 只向前连边:天然 DAG,环要另外显式植入(见 plantCycle)
    // window > 0 时只允许依赖"最近 window 个",图会变深(关键路径变长)
    const lo = window > 0 ? Math.max(0, i - window) : 0;
    for (let j = lo; j < i; j++) {
      if (rnd() < fanout && deps.length < 3) deps.push(`@mono/pkg-${pad(j)}`);
    }
    pkgs.push({ i, dirName: `pkg-${pad(i)}`, name, version: '1.0.0', deps });
  }

  for (const p of pkgs) {
    const dir = path.join(root, 'packages', p.dirName);
    fs.mkdirSync(path.join(dir, 'src'), { recursive: true });
    const dependencies = {};
    for (const d of p.deps) dependencies[d] = 'workspace:^';
    fs.writeFileSync(
      path.join(dir, 'package.json'),
      JSON.stringify(
        {
          name: p.name,
          version: p.version,
          type: 'module',
          main: './dist/index.mjs',
          exports: { '.': './dist/index.mjs' },
          scripts: { build: 'node ../../tools/build-one.mjs' },
          dependencies,
        },
        null,
        2,
      ) + '\n',
    );

    const importLines = p.deps
      .map((d, k) => `import { value as __d${k} } from '${d}';`)
      .join('\n');
    const body = [];
    for (let l = 0; l < linesPerFile; l++) {
      body.push(`export const v${pad(l, 3)} = ${(p.i * 131 + l * 17) % 9973};`);
    }
    const src = [
      importLines,
      body.join('\n'),
      `export const value = ${p.i} >= 0 ? (${[...p.deps.keys()].map((k) => `__d${k}`).join(' + ') || '0'}) + ${p.i} : 0;`,
      `export const pkgId = '${p.name}';`,
      '',
    ].join('\n');
    fs.writeFileSync(path.join(dir, 'src', 'index.mjs'), src);
  }

  writeRootFiles(root, pkgs);
  return pkgs;
}

function writeRootFiles(root, pkgs) {
  fs.writeFileSync(
    path.join(root, 'package.json'),
    JSON.stringify(
      {
        name: 'mono-root',
        private: true,
        type: 'module',
        workspaces: ['packages/*'],
      },
      null,
      2,
    ) + '\n',
  );
  fs.writeFileSync(path.join(root, 'README.md'), '# mono-root\n');
  return pkgs;
}

/** 把 workspace 包软链到 root/node_modules,模拟 npm/pnpm workspace 提升后的可见性 */
export function linkWorkspace(root, pkgs) {
  const nm = path.join(root, 'node_modules');
  fs.mkdirSync(nm, { recursive: true });
  for (const p of pkgs) {
    const targetAbs = path.join(root, 'packages', p.dirName);
    const link = path.join(nm, p.name); // 支持 @scope/name 两级
    fs.mkdirSync(path.dirname(link), { recursive: true });
    try {
      fs.unlinkSync(link);
    } catch {}
    fs.symlinkSync(path.relative(path.dirname(link), targetAbs), link, 'dir');
  }
  return nm;
}

/**
 * 植入环:低 -> 高 -> 中 -> 低("边"语义是"依赖")。
 * 只有一条反向边是造不出环的(图仍是无环的 DAG,只是顺序反了),必须闭环。
 */
export function plantCycle(root, low = 3, mid = 6, high = 9) {
  const add = (i, depI) => {
    const file = path.join(root, 'packages', `pkg-${pad(i)}`, 'package.json');
    const pj = JSON.parse(fs.readFileSync(file, 'utf8'));
    pj.dependencies[`@mono/pkg-${pad(depI)}`] = 'workspace:^';
    fs.writeFileSync(file, JSON.stringify(pj, null, 2) + '\n');
    return pj.name;
  };
  const chain = [];
  chain.push([low, high], [high, mid], [mid, low]);
  for (const [a, b] of chain) add(a, b);
  return {
    path: [`@mono/pkg-${pad(low)}`, `@mono/pkg-${pad(high)}`, `@mono/pkg-${pad(mid)}`, `@mono/pkg-${pad(low)}`],
    names: [`@mono/pkg-${pad(low)}`, `@mono/pkg-${pad(mid)}`, `@mono/pkg-${pad(high)}`],
  };
}

/**
 * 植入幽灵依赖:让 pkg 在 src 里 import 一个它没声明的兄弟包。
 * 因为 linkWorkspace 把所有包都链到了 root/node_modules,
 * Node 的向上查找一定能解析成功 ------ 这正是幽灵依赖的真实机制。
 */
export function plantPhantom(root, pkgI, depI) {
  const dir = path.join(root, 'packages', `pkg-${pad(pkgI)}`);
  const file = path.join(dir, 'src', 'index.mjs');
  const depName = `@mono/pkg-${pad(depI)}`;
  const src = fs.readFileSync(file, 'utf8');
  const pj = JSON.parse(fs.readFileSync(path.join(dir, 'package.json'), 'utf8'));
  if (pj.dependencies[depName]) throw new Error(`${depName} 已声明,无法作为幽灵依赖`);
  fs.writeFileSync(file, `import { value as __ghost } from '${depName}';\n${src}\nexport const ghost = __ghost;\n`);
  return { pkg: pj.name, dep: depName };
}

/** 把某个包改一行真实代码(内容变) */
export function touchContent(root, pkgI, tag = 'patched') {
  const file = path.join(root, 'packages', `pkg-${pad(pkgI)}`, 'src', 'index.mjs');
  const src = fs.readFileSync(file, 'utf8');
  fs.writeFileSync(file, src + `export const __tag = '${tag}-${Date.now()}'; // ${tag}\n`);
  return file;
}

/** 只改 mtime 不改内容(模拟 git checkout / 重新 clone / touch) */
export function touchMtimeOnly(root, pkgI, deltaMs = 1000) {
  const file = path.join(root, 'packages', `pkg-${pad(pkgI)}`, 'src', 'index.mjs');
  const st = fs.statSync(file);
  const t = new Date(st.mtimeMs + deltaMs);
  fs.utimesSync(file, t, t);
  return file;
}

/** 列出所有包目录(用来验证发现逻辑是否漏包) */
export function listPackageDirs(root) {
  const pk = path.join(root, 'packages');
  if (!fs.existsSync(pk)) return [];
  return fs
    .readdirSync(pk, { withFileTypes: true })
    .filter((d) => d.isDirectory())
    .map((d) => path.join(pk, d.name))
    .sort();
}

/**
 * 批量植入幽灵依赖:挑"目标包确实存在、但当前包 package.json 里没声明"的组合。
 * 返回 [{ pkg, dep }]。
 */
export function plantPhantoms(root, count, opt = {}) {
  const { startAt = 2 } = opt;
  const pk = path.join(root, 'packages');
  const dirs = fs.readdirSync(pk).filter((d) => fs.statSync(path.join(pk, d)).isDirectory()).sort();
  const planted = [];
  for (let i = startAt; i < dirs.length && planted.length < count; i++) {
    const dir = path.join(pk, dirs[i]);
    const pj = JSON.parse(fs.readFileSync(path.join(dir, 'package.json'), 'utf8'));
    let done = false;
    for (let j = i - 1; j >= 0 && !done; j--) {
      const depName = `@mono/pkg-${pad(j)}`;
      if (pj.dependencies[depName]) continue;
      planted.push(plantPhantom(root, Number(dirs[i].split('-')[1]), j));
      done = true;
    }
  }
  return planted;
}

/** 植入版本漂移:把包对某依赖的区间改成一个与 workspace 版本不匹配的范围 */
export function plantVersionSkew(root, pkgI, depI, range = '^2.0.0') {
  const dir = path.join(root, 'packages', `pkg-${pad(pkgI)}`);
  const file = path.join(dir, 'package.json');
  const pj = JSON.parse(fs.readFileSync(file, 'utf8'));
  const depName = `@mono/pkg-${pad(depI)}`;
  pj.dependencies[depName] = range;
  fs.writeFileSync(file, JSON.stringify(pj, null, 2) + '\n');
  return { pkg: pj.name, dep: depName, range };
}

test-day36.mjs ------ Monorepo 全套断言(570 行)

javascript 复制代码
// test-day36.mjs --- Day36 全量断言(零依赖,node test-day36.mjs)
import fs from 'node:fs';
import path from 'node:path';
import { spawnSync } from 'node:child_process';
import { pathToFileURL, fileURLToPath } from 'node:url';
import {
  makeRepo, linkWorkspace, plantCycle, plantPhantoms, plantVersionSkew,
  touchContent, touchMtimeOnly, listPackageDirs, rmrf, mulberry32,
} from './fixtures.mjs';
import { buildGraph, topoSort, findCycles, affected, reachableBruteforce, depthOf } from './graph.mjs';
import {
  scanImports, resolveNaive, resolveStrict, auditDependencies, findPhysicalCopies,
  resolveWorkspaceRange, checkVersionSkew, setupSingletonCase,
} from './deps.mjs';
import { runPipeline, cleanRebuild, diffOutputs, readDiskDist } from './tasks.mjs';
import { CacheStore, TOOLCHAIN_VERSION, sha } from './cache.mjs';

/* ---------------- 迷你断言框架 ---------------- */
let pass = 0;
const failures = [];
const ok = (cond, name, extra = '') => {
  if (cond) {
    pass++;
  } else {
    failures.push(`${name} ${extra}`);
    console.log(`  ✗ ${name} ${extra}`);
  }
};
const eq = (a, b, name) => ok(a === b, name, `期望 ${JSON.stringify(b)},实际 ${JSON.stringify(a)}`);
const ge = (a, b, name) => ok(a >= b, name, `期望 >= ${b},实际 ${a}`);
const gt = (a, b, name) => ok(a > b, name, `期望 > ${b},实际 ${a}`);
const lt = (a, b, name) => ok(a < b, name, `期望 < ${b},实际 ${a}`);
const section = (t) => console.log(`\n== ${t} ==`);

const BASE = process.env.DAY36_BASE || '/tmp/day36';
const HERE = path.dirname(fileURLToPath(import.meta.url)); // 脚本自身目录(与工作目录解耦)

/** 造一个干净仓库并建图 */
function fresh(name, opt = {}) {
  const root = path.join(BASE, `repo-${name}`);
  const pkgs = makeRepo(root, { count: 40, seed: 11, ...opt });
  linkWorkspace(root, pkgs);
  return { root, pkgs, graph: buildGraph(root) };
}

/** 用指定口径跑 cold + warm,返回统计 */
async function coldWarm(graph, scheme, opt = {}) {
  const cache = new CacheStore(path.join(graph.root, `.cache-${scheme}.json`));
  const cold = await runPipeline(graph, { scheme, cache, concurrency: 4, ...opt });
  const warm = await runPipeline(graph, { scheme, cache, concurrency: 4, ...opt });
  return { cache, cold, warm };
}

/**
 * 场景测量:mutation 之后,用 scheme 做增量,再用干净重建做 ground truth,
 * 返回 { executed, stale, affectedCount, wallMs }
 */
async function scenario(name, scheme, mutate, opt = {}) {
  const { root, graph } = fresh(name, opt.count ? { count: opt.count } : {});
  const { cache, cold } = await coldWarm(graph, scheme);
  eq(cold.executed, graph.packages.size, `${name}/${scheme}: 冷构建全部执行`);
  await mutate({ root, graph });
  const inc = await runPipeline(graph, { scheme, cache, concurrency: 4 });
  const clean = await cleanRebuild(graph, 4);
  const stale = diffOutputs(inc.outputShas, clean.outputShas);
  return { executed: inc.executed, hits: inc.hits, stale: stale.length, staleNames: stale, affected: affected(graph, new Set()).size, inc, clean };
}

console.log('Day36 Monorepo 断言套件');
console.log(`node ${process.version} / V8 ${process.versions.v8} / ${process.arch}`);

/* ================= A. 发现与图 ================= */
section('A. workspace 发现 / 拓扑序 / 环检测');
{
  const { root, graph } = fresh('graph');
  const dirs = listPackageDirs(root);
  eq(graph.packages.size, dirs.length, 'A1 发现的包数 == 磁盘上的包目录数');
  eq(graph.packages.size, 40, 'A2 发现的包数为 40');
  ok(dirs.length === 40, 'A3 listPackageDirs 也是 40(无遗漏)');

  const { order, remaining } = topoSort(graph);
  eq(remaining.length, 0, 'A4 干净图无剩余节点');
  eq(order.length, 40, 'A5 拓扑序覆盖全部包');

  const pos = new Map(order.map((n, i) => [n, i]));
  let violated = 0;
  // 边 from -> to 表示 from 依赖 to,所以 to 必须排在 from 之前
  for (const [from, tos] of graph.edges) for (const to of tos) if (!(pos.get(to) < pos.get(from))) violated++;
  eq(violated, 0, 'A6 每条依赖边都指向拓扑序更早的位置');

  // 确定性:打乱 edges 迭代顺序,拓扑序必须完全一致
  const shuffleGraph = (g, seed) => {
    const rnd = mulberry32(seed);
    const edges = new Map();
    const reverse = new Map();
    for (const n of g.packages.keys()) edges.set(n, new Set());
    for (const n of g.packages.keys()) reverse.set(n, new Set());
    for (const [from, tos] of [...g.edges.entries()].sort(() => rnd() - 0.5)) {
      for (const to of [...tos].sort(() => rnd() - 0.5)) {
        edges.get(from).add(to);
        reverse.get(to).add(from);
      }
    }
    return { ...g, edges, reverse };
  };
  const orders = [1, 2, 3, 4, 5].map((s) => topoSort(shuffleGraph(graph, s)).order.join(','));
  eq(new Set(orders).size, 1, 'A7 打乱 5 次枚举顺序,拓扑序逐字节相同');
  ok(orders[0] === order.join(','), 'A8 且与原始顺序的拓扑序一致');

  const depth = depthOf(graph, order);
  const maxDepth = Math.max(...depth.values());
  gt(maxDepth, 0, 'A9 图有多层(不是一层平铺)');
  gt(graph.workspaceEdges.length, 40, 'A10 workspace 内部边数 > 包数的一半');

  // 环
  eq(findCycles(graph).length, 0, 'A11 干净图 0 个环');
  const plantedCycle = plantCycle(root, 3, 6, 9);
  const g2 = buildGraph(root);
  const cycles = findCycles(g2);
  eq(cycles.length, 1, 'A12 植入 3 条边闭环后检出 1 个环');
  ok(
    plantedCycle.names.every((n) => cycles[0].includes(n)),
    'A13 环路径同时包含 03 / 06 / 09 三者',
    JSON.stringify(cycles[0]),
  );
  eq(cycles[0][0], cycles[0][cycles[0].length - 1], 'A13b 环路径首尾闭合');
  const bad = topoSort(g2);
  eq(bad.order.length, 40 - bad.remaining.length, 'A14 有环时拓扑序不完整');
  gt(bad.remaining.length, 0, 'A15 有环时 remaining 非空(编排必须拒绝启动)');
}

/* ================= B. 幽灵依赖 ================= */
section('B. 幽灵依赖:能解析 != 合法');
{
  const { root, graph } = fresh('phantom');
  const before = auditDependencies(graph);
  eq(before.findings.length, 0, 'B1 未植入时 0 个幽灵依赖');

  const planted = plantPhantoms(root, 3);
  eq(planted.length, 3, 'B2 成功植入 3 个幽灵依赖');
  const after = auditDependencies(graph);
  eq(after.findings.length, 3, 'B3 严格口径检出 3/3');

  let naiveResolved = 0;
  for (const p of planted) {
    const pkg = graph.packages.get(p.pkg);
    const r = resolveNaive(p.dep, pkg.dir, root);
    if (r.ok) naiveResolved++;
  }
  eq(naiveResolved, 3, 'B4 naive(Node 真实)解析 3/3 全部成功 ------ 所以"本地能跑"');

  let strictRejected = 0;
  for (const p of planted) {
    const pkg = graph.packages.get(p.pkg);
    const r = resolveStrict(pkg, p.dep, root);
    if (!r.ok && r.reason === 'undeclared') strictRejected++;
  }
  eq(strictRejected, 3, 'B5 strict 口径 3/3 以 undeclared 拒绝');

  // 只查 root/node_modules 的"全局视角"完全看不见这些包的问题
  let rootOnlySuspicious = 0;
  for (const p of planted) {
    if (!fs.existsSync(path.join(root, 'node_modules', p.dep))) rootOnlySuspicious++;
  }
  eq(rootOnlySuspicious, 0, 'B6 全局视角(root/node_modules)对 3 个幽灵依赖完全无感');
  ok(after.findings.every((f) => f.isWorkspacePeer), 'B7 3 个都是 workspace 兄弟包(severity=high)');
}

section('B1b. 幽灵依赖的事故复现:提供方消失');
{
  const { root, graph } = fresh('phantom-crash');
  await cleanRebuild(graph, 4); // 先让依赖的 dist 存在,否则测到的是"没构建"而不是"没依赖"
  const planted = (await import('./fixtures.mjs')).plantPhantoms(root, 3);
  const probeImport = path.join(HERE, 'probe-import.mjs');
  const fileOf = (pl) => path.join(root, 'packages', pl.pkg.replace('@mono/', ''), 'src', 'index.mjs');
  const tryImport = (file) => {
    const r = spawnSync(process.execPath, [probeImport, file], { encoding: 'utf8' });
    return JSON.parse(r.stdout.trim());
  };

  // 基线:提供方都在 -> 幽灵 import 照样成功
  let okBaseline = 0;
  for (const pl of planted) if (tryImport(fileOf(pl)).ok) okBaseline++;
  eq(okBaseline, 3, 'B10 提供方在场时 3/3 幽灵 import 成功(这就是"本地能跑")');

  // 提供方全部摘掉 -> 立刻崩
  const providers = [...new Set(planted.map((p) => p.dep))];
  for (const p of providers) fs.renameSync(path.join(root, 'node_modules', p), path.join(root, 'node_modules', `${p}.bak`));
  let crashed = 0;
  const codes = new Set();
  for (const pl of planted) {
    const r = tryImport(fileOf(pl));
    if (!r.ok) {
      crashed++;
      codes.add(r.code);
    }
  }
  eq(crashed, 3, `B11 摘掉提供方(${providers.length} 个)后 3/3 立刻崩`);
  ok(codes.has('ERR_MODULE_NOT_FOUND'), 'B12 错误码是 ERR_MODULE_NOT_FOUND', [...codes].join(','));

  for (const p of providers) fs.renameSync(path.join(root, 'node_modules', `${p}.bak`), path.join(root, 'node_modules', p));
  let okAgain = 0;
  for (const pl of planted) if (tryImport(fileOf(pl)).ok) okAgain++;
  eq(okAgain, 3, 'B13 装回去就又能跑 ------ 所以 CI 绿不代表没问题');
}

section('B2. import 扫描的语境判断');
{
  const tricky = [
    [`const a = 'import x from "./fake-str.mjs"';`, 0, '字符串里的 import 不算'],
    [`/*\nimport x from "./fake-block.mjs"\n*/\nconst b=1;`, 0, '块注释里的 import 不算'],
    ['const t = `\nimport x from "./fake-tpl.mjs"\n`;', 0, '模板字符串里的 import 不算'],
    ['const t = `${ `import x from "./nested.mjs"` }`;', 0, '嵌套模板里的 import 不算'],
    [`import a from 'real-1';`, 1, '真 import 算'],
    [`export { x } from 'real-2';`, 1, 'export-from 算'],
    [`import('real-3');`, 1, '动态 import 算'],
    [`import 'real-4';`, 1, '副作用 import 算'],
    [`const re = /import x from "fake-re.mjs"/;`, 0, '正则字面量里的 import 不算'],
    [`export const s = 'a'; import { y } from 'real-5';`, 1, '同行混排也算'],
  ];
  for (const [src, expect, label] of tricky) {
    eq(scanImports(src).length, expect, `B8 ${label}`);
  }
  const mixed = scanImports(`import { a as b } from '@mono/x';\nexport * from '@mono/y';\nimport('@mono/z');`);
  eq(mixed.map((m) => m.kind).join(','), 'static-import,export-from,dynamic-import', 'B9 三种形态的 kind 正确');
}

/* ================= C. 多副本 / 单例 ================= */
section('C. 多副本与单例破裂(子进程真实 import)');
{
  const { root } = fresh('singleton');
  setupSingletonCase(root);
  const probe = path.join(HERE, 'probe-singleton.mjs');

  const run = (layout, args = []) => {
    const r = spawnSync(process.execPath, [...args, probe, root, 'state-lib'], { encoding: 'utf8' });
    if (r.status !== 0) throw new Error(`probe 失败: ${r.stderr}`);
    return JSON.parse(r.stdout.trim());
  };

  // 场景 1:两份物理拷贝
  const dup = run('dup');
  eq(dup.realSame, false, 'C1 两份物理拷贝 realpath 不同');
  eq(dup.sharedState, false, 'C2 两份拷贝的模块状态不共享(inc 两次 + 一次 => 2 与 1)');
  eq(dup.stateA, 2, 'C3 A 侧状态 = 2');
  eq(dup.stateB, 1, 'C4 B 侧状态 = 1');
  eq(dup.instanceofAcrossCopies, false, 'C5 跨拷贝 instanceof 失败(Token 是两个不同的类)');
  eq(dup.sameClassRef, false, 'C6 类引用不相等');

  const copies = findPhysicalCopies(root, new Set(['state-lib']));
  eq(copies.get('state-lib').length, 2, 'C7 静态扫描找到 2 个物理拷贝');

  // 场景 2:嵌套那份换成指向提升副本的软链
  const nested = path.join(root, 'packages', 'pkg-00', 'node_modules', 'state-lib');
  rmrf(nested);
  fs.symlinkSync(path.relative(path.dirname(nested), path.join(root, 'node_modules', 'state-lib')), nested, 'dir');
  const linked = run('link');
  eq(linked.realSame, true, 'C8 软链去重后 realpath 相同');
  eq(linked.sharedState, true, 'C9 软链去重后状态共享(3 与 3)');
  eq(linked.stateA, 3, 'C10 共享后 A 侧 = 3(自己的两次 + 对方的这一次落在同一实例)');
  eq(linked.instanceofAcrossCopies, true, 'C11 跨路径 instanceof 通过');

  const copies2 = findPhysicalCopies(root, new Set(['state-lib']));
  const distinctReal = new Set(copies2.get('state-lib').map((c) => c.real)).size;
  eq(distinctReal, 1, 'C12 软链布局下真实副本只有 1 份(静态统计必须按 realpath 去重)');

  // 场景 3:软链 + --preserve-symlinks
  const preserved = run('preserved', ['--preserve-symlinks']);
  ok(typeof preserved.sharedState === 'boolean', 'C13 --preserve-symlinks 探针可运行');
  eq(preserved.preserveSymlinks, true, 'C14 子进程确实带上了 --preserve-symlinks');
  // 实测:realpath 相同,但模块实例依然分裂 ------ 按 URL 而不是按 realpath 建立模块表
  eq(preserved.realSame, true, 'C15 preserve-symlinks 下 realpath 仍然相同');
  eq(preserved.sharedState, false, 'C16 但模块状态已经不共享(实例按 URL 分裂)');
  eq(preserved.stateA, 2, 'C17 A 侧 = 2');
  eq(preserved.stateB, 1, 'C18 B 侧 = 1');
  eq(preserved.instanceofAcrossCopies, false, 'C19 instanceof 也随之失败');
  console.log(`  · --preserve-symlinks: realSame=${preserved.realSame} sharedState=${preserved.sharedState} stateA=${preserved.stateA} stateB=${preserved.stateB}`);
}

/* ================= D. 缓存口径 ================= */
section('D. 缓存口径:谁在骗你');
{
  // D1/D2 基础
  const { root, graph } = fresh('cache-basic');
  const { cache, cold, warm } = await coldWarm(graph, 'full');
  eq(cold.executed, 40, 'D1 full 冷构建 executed=40');
  eq(cold.hits, 0, 'D2 full 冷构建 hits=0');
  eq(warm.executed, 0, 'D3 full 热构建 executed=0(全部命中)');
  eq(warm.hits, 40, 'D4 full 热构建 hits=40');

  // 产物可执行
  const top = '@mono/pkg-39';
  const distFile = path.join(graph.packages.get(top).dir, 'dist', 'index.mjs');
  ok(fs.existsSync(distFile), 'D5 产物落在 dist/index.mjs');
  let distOk = true;
  try {
    const mod = await import(pathToFileURL(distFile).href + `?v=${Date.now()}`);
    distOk = typeof mod.value !== 'undefined' && typeof mod.pkgId === 'string';
  } catch (e) {
    distOk = false;
    console.log('  dist import 失败:', e.message);
  }
  ok(distOk, 'D6 产物是真正可被 ESM 加载的模块');

  // 删掉全部 dist,只留缓存 -> 应能恢复产物
  for (const p of graph.packages.values()) rmrf(path.join(p.dir, 'dist'));
  const restored = await runPipeline(graph, { scheme: 'full', cache, concurrency: 4 });
  eq(restored.executed, 0, 'D7 产物被删但缓存还在 => 0 次重建(从缓存恢复)');
  const clean0 = await cleanRebuild(graph, 4);
  eq(diffOutputs(restored.outputShas, clean0.outputShas).length, 0, 'D8 恢复出来的产物与干净重建逐字节相同');
}

section('D2. 五种口径 × 四种改动(stale = 与干净重建不一致的包数)');
const rows = [];
{
  // 场景 1:改一个"被依赖最多"的包(叶子)的内容
  const pickLeaf = (graph) => {
    let best = null;
    for (const [n, rs] of graph.reverse) if (!best || rs.size > graph.reverse.get(best).size) best = n;
    return best;
  };
  for (const scheme of ['full', 'srcOnly', 'mtime', 'fullNoToolchain']) {
    const { root, graph } = fresh(`leaf-${scheme}`);
    const { cache, cold } = await coldWarm(graph, scheme);
    const leaf = pickLeaf(graph);
    touchContent(root, Number(leaf.split('-').pop()), 'x');
    const inc = await runPipeline(graph, { scheme, cache, concurrency: 4 });
    const clean = await cleanRebuild(graph, 4);
    const stale = diffOutputs(inc.outputShas, clean.outputShas);
    rows.push({ scheme, case: '改叶子内容', executed: inc.executed, stale: stale.length, affected: affected(graph, new Set([leaf])).size, leaf });
    console.log(`  · [改叶子 ${leaf}] ${scheme}: executed=${inc.executed} stale=${stale.length} affected=${affected(graph, new Set([leaf])).size}`);
  }
  const full1 = rows.find((r) => r.scheme === 'full' && r.case === '改叶子内容');
  eq(full1.stale, 0, 'D9 full:改叶子后 stale=0(正确性)');
  eq(full1.executed, full1.affected, 'D10 full:只重跑受影响集合,不多不少');
  const src1 = rows.find((r) => r.scheme === 'srcOnly');
  eq(src1.executed, 1, 'D11 srcOnly:只重跑被改的 1 个包');
  ge(src1.stale, src1.affected - 1, 'D12 srcOnly:其余受影响包全部是旧产物(stale 数量 = 受影响 - 1)');
  const mt1 = rows.find((r) => r.scheme === 'mtime');
  ge(mt1.stale, mt1.affected - 1, 'D13 mtime:同样只重建 1 个,下游全旧');
}

{
  // 场景 2:git clone / npm ci 之后所有文件 mtime 变了但内容没变
  for (const scheme of ['full', 'srcOnly', 'mtime']) {
    const { root, graph } = fresh(`touch-${scheme}`);
    const { cache } = await coldWarm(graph, scheme);
    for (let i = 0; i < 40; i++) touchMtimeOnly(root, i, 1000);
    const inc = await runPipeline(graph, { scheme, cache, concurrency: 4 });
    const clean = await cleanRebuild(graph, 4);
    const stale = diffOutputs(inc.outputShas, clean.outputShas);
    rows.push({ scheme, case: '只改 mtime', executed: inc.executed, stale: stale.length });
    console.log(`  · [只改 mtime 全部 40 个] ${scheme}: executed=${inc.executed} stale=${stale.length}`);
  }
  const full2 = rows.find((r) => r.scheme === 'full' && r.case === '只改 mtime');
  eq(full2.executed, 0, 'D14 full:只改 mtime 不改内容 => 0 次重建');
  const mt2 = rows.find((r) => r.scheme === 'mtime' && r.case === '只改 mtime');
  eq(mt2.executed, 40, 'D15 mtime:同样的情况 => 40 次全量重建(100% 浪费)');
  eq(mt2.stale, 0, 'D16 但 mtime 这个场景下的正确性没问题(浪费不等于错)');
}

{
  // 场景 3:工具链升级
  for (const scheme of ['full', 'fullNoToolchain']) {
    const { root, graph } = fresh(`tc-${scheme}`);
    const { cache } = await coldWarm(graph, scheme);
    const inc = await runPipeline(graph, { scheme, cache, concurrency: 4, toolchain: 'mono-build/0.2.0' });
    const clean = await cleanRebuild(graph, 4);
    // clean 用的是默认工具链,为了让 ground truth 对齐,用同工具链再跑一次
    const clean2 = await runPipeline(graph, { scheme: 'nocache', concurrency: 4, toolchain: 'mono-build/0.2.0' });
    const stale = diffOutputs(inc.outputShas, clean2.outputShas);
    rows.push({ scheme, case: '工具链升级', executed: inc.executed, stale: stale.length });
    console.log(`  · [工具链 0.1.0 -> 0.2.0] ${scheme}: executed=${inc.executed} stale=${stale.length}`);
  }
  const f3 = rows.find((r) => r.scheme === 'full' && r.case === '工具链升级');
  eq(f3.executed, 40, 'D17 full:工具链变了 => 全部重建');
  eq(f3.stale, 0, 'D18 full:重建后产物与新版工具链的干净构建一致');
  const n3 = rows.find((r) => r.scheme === 'fullNoToolchain' && r.case === '工具链升级');
  eq(n3.executed, 0, 'D19 key 漏了工具链版本 => 0 次重建');
  eq(n3.stale, 40, 'D20 且 40 个产物全是旧编译器产物(100% 静默错误)');
}

{
  // 场景 4:改依赖声明区间(锁文件视角)
  const { root, graph } = fresh('range');
  const { cache } = await coldWarm(graph, 'full');
  const target = [...graph.packages.values()].find((p) => Object.keys(p.deps).length > 0);
  const depName = Object.keys(target.deps)[0];
  const pjFile = path.join(target.dir, 'package.json');
  const pj = JSON.parse(fs.readFileSync(pjFile, 'utf8'));
  pj.dependencies[depName] = '^1.5.0';
  fs.writeFileSync(pjFile, JSON.stringify(pj, null, 2) + '\n');
  const g2 = buildGraph(root);
  const inc = await runPipeline(g2, { scheme: 'full', cache, concurrency: 4 });
  const clean = await cleanRebuild(g2, 4);
  eq(inc.executed, 1, 'D21 只改自己声明的区间 => 只重跑自己(不牵连下游)');
  eq(diffOutputs(inc.outputShas, clean.outputShas).length, 0, 'D22 且产物依然一致');
}

{
  // 场景 5:改了又改回去 => 命中老缓存
  const { root, graph } = fresh('revert');
  const { cache } = await coldWarm(graph, 'full');
  touchContent(root, 20, 'first');
  const a = await runPipeline(graph, { scheme: 'full', cache, concurrency: 4 });
  gt(a.executed, 0, 'D23 第一次改动有重建');
  const file = path.join(graph.packages.get('@mono/pkg-20').dir, 'src', 'index.mjs');
  const original = fs.readFileSync(file, 'utf8').split("export const __tag")[0];
  fs.writeFileSync(file, original);
  const b = await runPipeline(graph, { scheme: 'full', cache, concurrency: 4 });
  eq(b.executed, 0, 'D24 改回原样 => 0 次重建(命中最初那批缓存条目)');
  const clean = await cleanRebuild(graph, 4);
  eq(diffOutputs(b.outputShas, clean.outputShas).length, 0, 'D25 回滚后产物正确');
}

section('D2b. 改内容但把 mtime 恢复原值(git checkout / rsync -t 的经典现场)');
{
  for (const scheme of ['full', 'mtime']) {
    const { root, graph } = fresh(`mtimeforge-${scheme}`);
    const { cache } = await coldWarm(graph, scheme);
    const i = 0; // 被依赖最多的叶子包,锥体最大
    const file = path.join(graph.packages.get(`@mono/pkg-${String(i).padStart(2, '0')}`).dir, 'src', 'index.mjs');
    const st = fs.statSync(file);
    touchContent(root, i, 'forged');
    fs.utimesSync(file, st.atime, st.mtime); // 内容变了,时间戳原封不动
    const inc = await runPipeline(graph, { scheme, cache, concurrency: 4 });
    const clean = await cleanRebuild(graph, 4);
    const stale = diffOutputs(inc.outputShas, clean.outputShas);
    rows.push({ scheme, case: '改内容+伪造mtime', executed: inc.executed, stale: stale.length });
    console.log(`  · [改内容但 mtime 不变] ${scheme}: executed=${inc.executed} stale=${stale.length}`);
    if (scheme === 'full') {
      eq(stale.length, 0, 'D30 full:内容变了就一定重建(不看时间戳)');
      gt(inc.executed, 0, 'D31 full:executed > 0');
    } else {
      eq(inc.executed, 0, 'D32 mtime:内容变了但时间戳没变 => 0 次重建');
      gt(stale.length, 0, 'D33 mtime:于是整条下游全是旧产物(时间戳口径的静默错误)');
    }
  }
}

section('D3. 随机单点改动 12 次,full 的 stale 必须恒为 0');
{
  const { root, graph } = fresh('random', { count: 30, seed: 99 });
  const { cache } = await coldWarm(graph, 'full');
  const names = [...graph.packages.keys()].sort();
  const rnd = mulberry32(7);
  let bad = 0;
  let totalExecuted = 0;
  for (let k = 0; k < 12; k++) {
    const i = Math.floor(rnd() * names.length);
    touchContent(root, i, `r${k}`);
    const inc = await runPipeline(graph, { scheme: 'full', cache, concurrency: 4 });
    const clean = await cleanRebuild(graph, 4);
    const stale = diffOutputs(inc.outputShas, clean.outputShas).length;
    if (stale !== 0) bad++;
    totalExecuted += inc.executed;
  }
  eq(bad, 0, 'D26 12 次随机改动 stale 全部为 0');
  gt(totalExecuted, 0, 'D27 期间确实发生了重建(不是躺赢)');
}

section('D4. 受影响集合与暴力可达性差分');
{
  const { graph } = fresh('affected');
  let mismatch = 0;
  for (const n of graph.packages.keys()) {
    const a = [...affected(graph, new Set([n]))].sort().join(',');
    const b = [...reachableBruteforce(graph, new Set([n]))].sort().join(',');
    if (a !== b) mismatch++;
  }
  eq(mismatch, 0, 'D28 40 个包逐个对比 affected == 暴力可达性');

  const multi = [...graph.packages.keys()].slice(0, 3);
  const a = [...affected(graph, new Set(multi))].sort().join(',');
  const b = [...reachableBruteforce(graph, new Set(multi))].sort().join(',');
  eq(a, b, 'D29 多变更点同时对得上');
}

/* ================= E. 编排 ================= */
section('E. 编排:并发 / 一致性');
{
  const { graph } = fresh('sched', { count: 16, seed: 5 });
  // 计时抖动大:每种并发度跑 3 次取最小值
  const best = async (c, tag) => {
    let min = Infinity;
    let first = null;
    for (let r = 0; r < 3; r++) {
      const cache = new CacheStore(path.join(graph.root, `.c-${tag}-${r}.json`));
      const res = await runPipeline(graph, { scheme: 'full', cache, concurrency: c, delayMs: 4 });
      if (!first) first = res;
      min = Math.min(min, res.wallMs);
    }
    return { wallMs: min, first };
  };
  const serial = await best(1, 'serial');
  const par = await best(4, 'par');
  eq(serial.first.executed, 16, 'E1 串行执行 16 个任务');
  eq(par.first.executed, 16, 'E2 并发执行 16 个任务');
  eq(diffOutputs(serial.first.outputShas, par.first.outputShas).length, 0, 'E3 并发与串行产物逐字节相同');
  gt(serial.wallMs / Math.max(par.wallMs, 0.001), 1.5, 'E4 4 并发明显快于串行(每任务 4ms 人造耗时)');
  console.log(`  · 16 任务 (每任务 4ms, 3 次取最小): serial=${serial.wallMs.toFixed(1)}ms par4=${par.wallMs.toFixed(1)}ms 加速=${(serial.wallMs / par.wallMs).toFixed(2)}x`);

  const par8 = await best(8, 'p8');
  ok(par8.first.executed === 16, 'E5 8 并发也执行了 16 个任务');
  gt(par8.wallMs, 0, 'E6 8 并发的 wall time 有值');
}

section('E1b. 关键路径才是并行天花板(宽图 / 深图 / 纯链)');
{
  const wideG = fresh('parw', { count: 120, seed: 77 }).graph;
  const chainG = fresh('parchain', { count: 120, seed: 77, window: 1, fanout: 1 }).graph;
  const wall = async (g, c, tag) => {
    let best = Infinity;
    for (let r = 0; r < 3; r++) {
      const cache = new CacheStore(path.join(g.root, `.c-${tag}-${c}-${r}.json`));
      const res = await runPipeline(g, { scheme: 'full', cache, concurrency: c, delayMs: 2 });
      best = Math.min(best, res.wallMs);
    }
    return best;
  };
  const depthOfChain = Math.max(...depthOf(chainG, topoSort(chainG).order).values());
  gt(depthOfChain, 100, 'E20 纯链图的层深接近包数(图退化成一条链)');

  const w1 = await wall(wideG, 1, 'w');
  const w16 = await wall(wideG, 16, 'w');
  const c1 = await wall(chainG, 1, 'c');
  const c16 = await wall(chainG, 16, 'c');
  const wideSpeed = w1 / w16;
  const chainSpeed = c1 / c16;

  // 不变量:wall time 不可能低于"关键路径 × 单任务耗时"
  ge(w16 * 16, 120 * 2, 'E21 宽图:wall×并发 >= 总工作量(不会凭空更快)');
  ge(c16, depthOfChain * 2 * 0.9, 'E22 纯链:wall >= 关键路径 × 单任务耗时(并发打不穿关键路径)');
  lt(chainSpeed, 2, 'E23 纯链图开 16 并发基本没有加速(< 2x)');
  gt(wideSpeed, 3, 'E24 宽图确实吃到了并发(> 3x)');
  console.log(`  · 120 包 × 2ms @16并发:宽图(层深7) ${wideSpeed.toFixed(2)}x,纯链(层深${depthOfChain}) ${chainSpeed.toFixed(2)}x`);
}

section('E2. workspace 协议与版本漂移');
{
  eq(resolveWorkspaceRange('workspace:^', '1.0.0').mode, 'link', 'E7 workspace:^ => 本地链接');
  eq(resolveWorkspaceRange('workspace:*', '9.9.9').mode, 'link', 'E8 workspace:* 与版本无关,永远链接');
  eq(resolveWorkspaceRange('workspace:1.0.0', '1.0.0').ok, true, 'E9 workspace:精确版本 命中也链接');
  eq(resolveWorkspaceRange('workspace:2.0.0', '1.0.0').ok, false, 'E10 workspace 钉死版本不符 => 不可用');
  eq(resolveWorkspaceRange('^1.0.0', '1.0.0').mode, 'registry', 'E11 普通 semver 区间 => registry 语义');

  const { root, graph } = fresh('skew');
  eq(checkVersionSkew(graph).length, 0, 'E12 干净仓库无版本漂移');
  plantVersionSkew(root, 20, 5, '^2.0.0');
  const g2 = buildGraph(root);
  const skews = checkVersionSkew(g2);
  eq(skews.length, 1, 'E13 植入 1 处漂移被检出');
  eq(skews[0].kind, 'range-vs-workspace', 'E14 漂移类型正确(区间主版本与本地不符)');

  // 关键:漂移不影响本地解析(仍解析到 1.0.0),所以本地永远发现不了
  const pkg = g2.packages.get('@mono/pkg-20');
  const r = resolveNaive('@mono/pkg-05', pkg.dir, root);
  eq(r.ok, true, 'E15 漂移的依赖本地仍然解析成功 ------ 本地永远发现不了');
  eq(JSON.parse(fs.readFileSync(path.join(r.realDir, 'package.json'), 'utf8')).version, '1.0.0', 'E16 解析到的是 workspace 的 1.0.0(与声明的 ^2.0.0 矛盾)');
}

/* ---------------- 汇总 ---------------- */
console.log(`\n${'='.repeat(56)}`);
if (failures.length) {
  console.log(`✗ ${failures.length} 条失败 / 共 ${pass + failures.length} 条`);
  for (const f of failures) console.log('   - ' + f);
  process.exit(1);
}
console.log(`✓ ALL ${pass} ASSERTIONS GREEN`);
process.exit(0);

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

javascript 复制代码
// extract-and-test.mjs --- 把正文里的 ```js 代码块抽出来逐个 node --check,并统计字数
// 用法: node extract-and-test.mjs [mdPath]
import fs from 'node:fs';
import path from 'node:path';
import os from 'node:os';
import { spawnSync } from 'node:child_process';
import { fileURLToPath } from 'node:url';

const here = path.dirname(fileURLToPath(import.meta.url));
const candidates = [
  process.argv[2],
  path.join(here, 'Day36-Monorepo.md'),
  path.join(here, '..', 'Day36-Monorepo.md'),
  path.join(here, '..', '..', 'Day36-Monorepo.md'),
].filter(Boolean);

let mdPath = candidates.find((p) => fs.existsSync(p));
if (!mdPath) {
  console.error('找不到 Day36-Monorepo.md,请把本脚本和 md 放在同一目录或传路径参数');
  process.exit(2);
}
mdPath = path.resolve(mdPath);

const md = fs.readFileSync(mdPath, 'utf8');
const blocks = [...md.matchAll(/```js\n([\s\S]*?)```/g)].map((m) => m[1]);
console.log(`markdown: ${mdPath}`);
console.log(`大小: ${Buffer.byteLength(md)}B / ${md.split('\n').length} 行`);

// 汉字统计:只算汉字,不算标点与代码
const cjk = (s) => (s.match(/[\u4e00-\u9fff]/g) || []).length;
const bodyNoCode = md.replace(/```[\s\S]*?```/g, '');
const han = cjk(bodyNoCode);
const hanAll = cjk(md.replace(/`[^`\n]*`/g, ''));
console.log(`正文汉字: ${han}(含行内代码 ${hanAll})`);

// 逐个 node --check
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'day36-code-'));
let fail = 0;
blocks.forEach((code, i) => {
  const f = path.join(tmp, `block-${String(i + 1).padStart(2, '0')}.mjs`);
  fs.writeFileSync(f, code);
  const r = spawnSync(process.execPath, ['--check', f], { encoding: 'utf8' });
  const lines = code.split('\n').length;
  if (r.status === 0) {
    console.log(`  ✓ 代码块 ${i + 1}(${lines} 行,${Buffer.byteLength(code)}B)`);
  } else {
    fail++;
    console.log(`  ✗ 代码块 ${i + 1}(${lines} 行)`);
    console.log(String(r.stderr).split('\n').slice(0, 4).join('\n'));
  }
});
fs.rmSync(tmp, { recursive: true, force: true });

// 结构检查
const need = ['# Day36-Monorepo', '## 一、技术难点', '## 二、完整解法', '## 三、应用场景', '## 四、总结'];
const missing = need.filter((h) => !md.includes(h));
if (missing.length) {
  console.log(`  ✗ 缺少章节: ${missing.join(' / ')}`);
  fail++;
} else {
  console.log('  ✓ 四个分节齐全');
}
if (han < 1500) {
  console.log(`  ✗ 正文汉字 ${han} < 1500`);
  fail++;
} else {
  console.log(`  ✓ 正文汉字 ${han} >= 1500`);
}

console.log(fail ? `\n✗ ${fail} 项失败` : `\n✓ ${blocks.length} 个代码块全部通过 node --check,结构完整`);
process.exit(fail ? 1 : 0);
相关推荐
陈随易2 小时前
C++26 草案被否,二十八年来头一回
前端·后端·程序员
ZhengEnCi2 小时前
Agent 工具怎么折叠海量输出才「爽」:Trae、Claude Code 的渐进式披露设计原理
前端·agent
IT_陈寒5 小时前
Redis内存暴涨,没想到是AOF重写捣的鬼
前端·人工智能·后端
ikoala5 小时前
让 Codex 自己查账:一个省 token 的小技巧
前端·后端·ai编程
ikoala6 小时前
AI 写页面像说明书,修 Bug 像贴膏药?把这两句话写进 AGENTS.md
前端·javascript·ai编程
去伪存真8 小时前
AI Agent Harness 深度测评与技术选型实战
前端·agent
FRing0311 小时前
vue2干货总结7(自定义指令、插槽、单页应用程序、路由的使用(VueRouter))
开发语言·前端·vue.js·vue2
疯华整猫14 小时前
被杀掉之后:我把 Android 的「唤起 + 常驻」重做了一遍
前端·面试·架构
IT_陈寒16 小时前
Java开发实战:从入门到精通
前端·人工智能·后端
风骏时光牛马16 小时前
生成一个AI转岗经历标题,只需要一个标题,不需要其他的任何文本。
前端