ADR-0019: 共享 tsconfig 预设包 与「无 .mjs」约束
Date: 2026-09-23 Status: Accepted
Context
三件事在同一轮里被摆到台面上:
packages/tooling/scripts一个包装了太多东西 —— 11 个通用lib模块 + 20 个发版模块 + 2 个门禁 + 1 个 env 工具 + 4 个 bin,职责横跨「通用能力」「仓库门禁」「发版编排」「env 加解密」。tsconfig一直是根部的tsconfig.base.json,每个包用../../../tsconfig.base.json这种 按目录深度的相对路径继承。仓内已有 12 个包 + 根自己,深度还不一致(apps/*是两级、packages/*/*是三级)。ADR 0010 与monorepo/typescript.md曾明确写过「不提取共享 tsconfig 包」, 理由是「12 包规模下 root base 足够」。- 仓库里有 16 个
.mjs文件(5 个 eslint 配置:根 + 3 个 app + ui 包;eslint-config 的 4 个预设, 含一把 249 行的 NestJS 装饰器排序规则插件;commitlint 配置与 config 包;contract 的 build-barrel 脚本; 4 个 bin),与仓内其余的.ts代码形成两套心智模型。
Decision
1. packages/tooling/ 拆成 5 个包,按耦合度切
packages/tooling/
├── tsconfig/ @walnut/tsconfig 纯 JSON 预设(base / ts / vue)
├── eslint-config/ @walnut/eslint-config ESLint 预设(base / vue / nest)
├── commitlint-config/ @walnut/commitlint-config commitlint 规则
├── scripts/ @walnut/scripts src/lib + src/ci + src/env,3 个 bin
└── release/ @walnut/release src/release,1 个 bin(walnut-release)2026-09-23 后续更新:同日又新增
vitest-config/(@walnut/vitest-config,共享 Vitest 预设), tooling 组随之变大;@walnut/scripts的 bin 也增加了(新增门禁walnut-check-doc-refs)。 上面的目录树保留决策当时的样子 —— 拆分的判据(按耦合度切,而不是按能力切)未变,vitest-config走的就是同一条判据。
分界线一句话:这个模块认识「发版」吗? 认识 → release/;不认识(纯工具、git 查询、子进程、 提示、门禁、env)→ scripts/。
@walnut/scripts只用exports: { "./lib/*": "./src/lib/*.ts" }对外暴露通用能力;ci/与env/刻意不导出 —— 它们是本包 bin 的实现,导出去等于承诺两个没人调用却要维持兼容的入口。@walnut/release依赖@walnut/scripts,按需 importlib(10 个模块)。lib里不许出现「发版 / 包 / 意图」这类业务词;本包需要通用能力时上提到lib。
2. 提取 @walnut/tsconfig,删掉根 tsconfig.base.json
三个预设按运行环境分,不按「项目 / 库」分:
| 预设 | 用于 | 关键差异 |
|---|---|---|
base.json | 仓库根 tsconfig.json、@walnut/eslint-config、@walnut/commitlint-config | 只有与环境无关的语义 |
ts.json | platform-any/*、tooling/{scripts,release} | + erasableSyntaxOnly |
vue.json | apps/admin、apps/docs、platform-web/* | + DOM lib, jsx, jsxImportSource: vue |
base.json不含lib之外的任何环境假设(jsx/jsxImportSource/types/erasableSyntaxOnly):它们都是环境假设。原 base 把jsx: preserve+jsxImportSource: vue放进基线, 于是platform-any/*(纯逻辑包)也拿到了 Vue JSX 支持 —— 一个假阴性,本轮一并修掉。- 三层不是「继承链的中间层」:
base.json有三个真实直接消费者,它们都由 jiti / commitlint 的 TS loader 加载(不经 Node 原生类型剥离),因此既不需要erasableSyntaxOnly、也不需要 DOM lib。 @walnut/tsconfig自己的types:check仍是echo—— 包里只有纯 JSON 预设,tsc无事可做。apps/server/tsconfig.json仍然完全不 extends 任何预设(ADR 0012 的理由不变: CJS +moduleResolution: node+ decorators 与 ESM + bundler 冲突)。
为什么推翻了 ADR 0010 / typescript.md 里「不提取」的结论:当时的理由是「12 包规模下 root base 足够」,那条判断只算了数量,没算分化。实际情况是三个环境已经长出了不同需求 (Node 原生执行 / 浏览器+Vue / 纯 JSON),而每个消费者还要写不同深度的 ../../../。提取之后 「这个包在什么环境里跑」由一个预设名表达,且包挪到哪一层都不用改继承路径。
3. 全仓消除 .mjs,去掉 tsx
- 16 个
.mjs全部改成.ts(含那把 249 行的 NestJS 装饰器排序规则插件 —— 它此前是无类型的实验代码, 本轮补齐类型并让它过tsc --noEmit)。 - bin 由 Node 24 的原生类型剥离执行,不再
import 'tsx/esm';tsx也从仓库彻底移除 —— 最后一个使用者是apps/admin的predev/types:check:log,两个脚本改走node,tsx的 devDependency 与 catalog 条目一并删掉。 代价是那两条链路必须满足 Node ESM 的解析规则:build/utils/**的相对导入要写全.ts扩展名 (Node 不做扩展名推断),build/utils/log.ts里import pkg from '../../package.json'要补with { type: 'json' }导入属性(ESM 不会替你猜 JSON 的模块类型)。 - 该不变量由
ts.json的erasableSyntaxOnly: true在编译期保证: 源码只要用了enum/ 非 ambientnamespace/ 构造函数参数属性,pnpm types:check当场报错 —— 而不是等到某个 bin 被调用时才炸。 - ESLint 加载
eslint.config.ts需要jiti,因此根 devDependencies 新增它。
为什么值得:.mjs 与 .ts 并存意味着「哪些文件能写类型、哪些不能」要靠记忆。 统一成 .ts 后,配置、预设、bin、构建脚本与业务代码是同一套心智模型,类型检查也覆盖到了配置层 (@walnut/eslint-config 的 types:check 此前是一句空跑的 echo,现在是真正的 tsc --noEmit)。
Alternatives considered
决策 1:packages/tooling/ 拆成 5 个包,按耦合度切
- 按能力切成
script-lib+release+checks+env(6~7 包) ——ci/只有 2 个模块、env/只有 1 个,为它们各建一个包,收益(可读性)低于成本(每个包一套package.json+tsconfig.json+turbo.json+ 依赖声明);按耦合度切让每一边都足够大且内聚。 - 学参考仓,用一个
@zhenfei/tooling装下scripts/{build,ci,dev,lib,local,release}—— 参考仓的 tooling 是单包多目录,本仓的release已经长到 20 个模块、且有独立的测试面与依赖(git-cliff、yaml),把它留在一个「什么都装」的包里会让「谁依赖 git-cliff」这类问题无法从package.json读出来。
决策 2:提取 @walnut/tsconfig,删掉根 tsconfig.base.json
- 不提取共享 tsconfig 包,每个包继续用
../../../tsconfig.base.json这类按目录深度的相对路径继承 —— ADR 0010 与monorepo/typescript.md当时的理由是「12 包规模下 root base 足够」,但那笔账只算了数量、没算分化:Node 原生执行 / 浏览器 + Vue / 纯 JSON 三类环境已经长出不同需求,且每个消费者还要写不同深度的../../../。
决策 3:全仓消除 .mjs,去掉 tsx
- 保留 16 个
.mjs(含继续用tsx执行 bin) ——.mjs与.ts并存意味着「哪些文件能写类型、哪些不能」要靠记忆;统一成.ts后,配置、预设、bin、构建脚本与业务代码是同一套心智模型,类型检查也覆盖到了配置层(@walnut/eslint-config的types:check从空跑的echo变成真正的tsc --noEmit)。
Consequences
变得更好:
- 依赖边界可从
package.json读出:git-cliff/yaml在@walnut/release,@dotenvx/dotenvx在@walnut/scripts,@antfu/eslint-config在@walnut/eslint-config。 - 根
package.json的 devDependencies 18 → 19 项:去掉@antfu/eslint-config/@dotenvx/dotenvx/@walnut/tooling(都是「只有某个包用」),加上@walnut/release/@walnut/scripts/@walnut/tsconfig(根要经它们的 bin 与 tsconfig 预设)与jiti。 净增 1 项,但每一项都是根自己真的用的 —— 不再是「某个包的依赖搭根的车」。 - 类型检查覆盖到配置层与工具链(
@walnut/eslint-config与@walnut/commitlint-config的types:check是真tsc --noEmit;只有纯 JSON 的@walnut/tsconfig保持echo)。 erasableSyntaxOnly把「Node 能不能跑这个文件」从运行期问题变成编译期问题。
代价:
packages/tooling/从 3 个目录变成 5 个包,每个包一套package.json+tsconfig.json+turbo.json。- 新增
jiti一个依赖(ESLint 加载 TS 配置的官方要求)。 versioning.fixed单组从 12 个包涨到 14 个(新增@walnut/tsconfig与@walnut/release,@walnut/tooling消失)。nest.ts里对./nest-local-rules的导入刻意不写.ts扩展名 —— 它会被消费方 (apps/server)的类型程序一起编译,而 server 的 tsconfig 按 ADR 0012 是自包含的、没有开allowImportingTsExtensions。写扩展名会让@walnut/server的 types:check 直接报 TS5097。
顺带修掉的三个真实缺陷:
- 重写那把装饰器排序插件时把顺序表里的
ApiWalnutOkResponse写成了WalnutAdminOkResponse, 导致 11 个 controller 报出假违规。已用「三张顺序表与原文逐项比对」的方式验证一致 (38 / 17 / 3 项全等)。 - 根级配置(
eslint.config.ts/commitlint.config.ts/knip.config.ts)此前不被任何门禁覆盖 ——prepush与 CI 都只跑包内任务。本轮把pnpm lint:root加进prepush(当时为六段, 2026-09-23 又补pnpm types:check:root后为七段)与ci.yml的 quality job。 - 发版电池(
RELEASE_BATTERY)里有一条写的是turbo run lint lint:root,但lint:root只是根package.json的脚本、不是 turbo 任务 ⇒ 该命令以Could not find task 'lint:root' in project非 0 退出,每一次发版都会在打 tag 之前被它拦下。已拆成两行:turbo run lint+pnpm lint:root。