一、技术难点
Day35 讲的是"产物怎么被造出来"------一个仓库、一个入口、一张模块图。这一篇往上走一层:几十个包躺在同一个仓库里。
Monorepo 的地基不是目录结构,也不是 pnpm -r build 这条命令。它是三张表:
- 谁依赖谁 ------ 包图、拓扑序、环;
- 每个包能看到什么 ------ 依赖解析口径,决定了"能跑"和"合法"之间的距离;
- 一次改动会让什么失效 ------ 缓存 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
判据不是"包多",是包之间的耦合是否需要一个原子提交。出现下面任一条,收益通常是正的:
- 改 A 必须同步改 B(协议、类型、迁移脚本);
- 存在"全仓库一起升级"的动作(Node 版本、TS 版本、lint 规则);
- 需要跨包的原子重构(重命名一个公共 API);
- 需要统一的可观测性/构建/发布通道。
反过来,如果包之间几乎零耦合、发布节奏完全不同、团队之间不愿共享 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 发布流水线里最容易翻车的三处
- 版本漂移(§2.5):发布前必须扫,本地绿不代表发布绿。
- 多副本进产物 :
dependencies与peerDependencies放错位,构建产物里就会出现两份库。判定方法只有一个------在产物里 import 两次比实例,不要看 lockfile。 - 缓存跨分支复用 :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 |
四句话收尾:
- "能跑"与"合法"是两件事。 提升让未声明的依赖真的装好了,所以任何"看装没装"的检查都是 0 检出率;判据只能是"这个包自己声明了吗"。
- 单例的判据是模块实例,不是路径,也不是版本。 软链能去重,但
--preserve-symlinks一句话就能把它废掉------因为模块表按 URL 建。 - 缓存 key 的正确性由"漏了什么"决定,不由"有没有 hash"决定。 四种漏法里有两种会让 CI 全绿而产物全错(47/120、120/120),且不报任何错。
- 增量的收益取决于锥体,并行的收益取决于关键路径。 这两件事都不在"增量"和"并发"这两个词里,在你的图里------所以先量图。
配套文件(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);