用 dependency-cruiser 为 TypeScript 仓库强制实施 Deep Modules:入口文件边界的完整落地指南
【免费下载链接】skillsSkills for Real Engineers. Straight from my .agents directory.项目地址: https://gitcode.com/GitHub_Trending/skills13/skills
本指南基于
skills/in-progress/setup-ts-deep-modules/SKILL.md及其配套的 dependency-cruiser.config.cjs,讲述如何把 TypeScript 仓库中的每个包改造成“深模块”(deep module):把大量行为隐藏在少量入口文件之后,让包的根目录文件成为唯一对外通道。读完本文,你将掌握完整的接线流程、四条边界规则的底层正则原理、如何用一次“故意破坏”验证规则真正生效,以及如何把这一约定固化到 Agent 的工作流中。
什么是 Deep Module:小接口背后的大行为
先建立共享词汇。本仓库的 codebase-design 技能为“深模块”提供了精确的术语体系,setup-ts-deep-modules 全程使用这套语言:
- 模块(Module):任何同时拥有接口与实现的东西,刻意与规模无关,可以是一个函数、一个类、一个包,也可以是一个跨层切片;
- 接口(Interface):调用方正确使用模块所需知道的一切,不只是类型签名,还包括不变量、顺序约束、错误模式、所需配置与性能特征;
- 深度(Depth):接口处的杠杆率,即调用方(或测试)每学习一份接口所能调用的行为量。深模块= 小接口 + 大量实现;浅模块= 大接口 + 几乎空的实现(要避免);
- 缝(Seam):接口所坐落的位置,可以在不改动原处的情况下改变行为;
- 杠杆(Leverage):调用方从深度中获得的好处,一份实现回馈给 N 个调用点和 M 个测试;
- 局部性(Locality):维护者从深度中获得的好处,变更、缺陷、知识与验证集中在一处而不是扩散到所有调用方。
正如 codebase-design 指出的,深度是接口的属性而非实现的属性:一个深模块内部可以继续由许多小而可替换的部件组成,它们只是不属于接口而已。setup-ts-deep-modules 所做的,正是把“每个包都是深模块”这一设计目标,变成可被 CI 强制执行的工程约束。
本技能强制塑造的目录形态
技能要求仓库形成如下的统一形态:
src/packages/ <name>/ index.ts ← 入口文件(公开)。外部只能从这里 import。 client.ts ← 另一个入口文件。一个包可以暴露多个入口。 lib/ ← 实现:对外隐藏,内部文件之间可自由互相 import。 tests/ ← 与代码同目录的测试与 fixtures(子目录,属于私有)。核心判断标准有三条:
- 公开面 = 包的根目录文件,而不是某个指定的
index.ts; - 按惯例,实现放在
lib/,测试放在tests/,让每个包都有相同的两文件夹形态; - 规则本身是通用的:任何子文件夹里的任何东西都是私有的,因此将来新增文件夹时永远不需要改配置。
注意这里明确区分了“入口文件”与“桶文件(barrel)”:
入口文件,而不是桶文件。因为公开面是每一个根目录文件,一个包可以暴露多个小而精的入口(
index.ts、client.ts、server.ts),而不是把一切塞进一个巨型index.ts。鼓励保持入口文件小而隐藏实现,明确不鼓励那些把整棵子树重新导出的 barrel 文件。
分层(哪些包可以依赖哪些包)是一个独立的关注点,配置文件里已为它留好了注释形式的占位(见后文)。
四条边界规则:全部 error 级别
规则一共四条(加上一条禁环),全部以error级别生效,意味着任何违反都会让lint:boundaries失败:
- 入口边界(Entry-point boundary):包外代码(应用代码或另一个包)只允许导入该包的入口文件(根目录文件),绝不能触碰其子文件夹里的任何东西。
- 包内自由(Intra-package freedom):一个包自己的文件之间可以自由互相导入。
- 测试走入口(Tests through the entry points):
<pkg>/tests/下的文件可以导入任意包的入口文件以及自己的tests/fixtures,但绝不能导入任何包的子文件夹内部实现(包括自己包的)。跨包的集成测试没问题,深导入不行。 - 禁环(No cycles):不允许出现依赖环。
第四条规则在配置中以no-circular呈现,其余三条分别对应配置中的entrypoint-boundary-from-app、entrypoint-boundary-across-packages、tests-through-entrypoints与tests-folder-is-private。注意实际上配置里是5 条 forbidden 规则:除了技能正文列出的四条,还多出一条tests-folder-is-private(一个包的tests/目录只允许测试自身访问,防止其他代码误导入测试 fixtures)。这四条加一条共同构成了完整的边界体系。
七步接线流程
技能把整个落地过程拆成七个可验证的步骤,每步都带明确的 “Done when” 验收条件。
第 1 步:探测环境
- 包管理器:
pnpm-lock.yaml→ pnpm;yarn.lock→ yarn;bun.lockb→ bun;否则 npm。后续所有命令都要用探测到的那个管理器(pnpm/yarn/npm run/bunx)。 - 包根目录:如果存在
src/就用src/packages,否则用packages。若仓库已有明显不同的惯例,应与用户确认。 - 已有配置:检查是否存在
.dependency-cruiser.*文件。若已存在,不要覆盖:把四条规则与 options 合并进去,并明确告诉用户你添加了什么。
验收:包管理器、包根目录、已有配置状态三者都已确定。
第 2 步:安装 dependency-cruiser
用探测到的包管理器把dependency-cruiser安装为 devDependency。
验收:dependency-cruiser出现在devDependencies中。
第 3 步:编写配置
把仓库自带的 dependency-cruiser.config.cjs 复制到仓库根目录并命名为.dependency-cruiser.cjs,然后把PACKAGES_ROOT设为第 1 步探测到的根目录。规则基于路径深度且与扩展名无关,因此除此之外无需任何适配。
验收:.dependency-cruiser.cjs存在、PACKAGES_ROOT正确、四条禁止规则齐全。
第 4 步:接入检查命令
- 新增
lint:boundaries脚本:depcruise <packages-root>(或depcruise src)。 - 把它并入仓库已有的总检查命令(那个已经跑 typecheck 的
check/ci/validate脚本)。不要改动 tsconfig,也不要添加路径别名。 - 如果没有总检查脚本,就只加
lint:boundaries,并告知用户应把它纳入 CI。
验收:lint:boundaries存在,并与 typecheck 在同一条命令中执行。
第 5 步:搭建示例包
在<packages-root>/example/创建可提交的“复制即用”模板:
index.ts:一个入口文件,导出一个委托给内部文件的函数(让包看起来有深度,而不是一个透传壳);lib/impl.ts:子文件夹中的内部文件,被index.ts导入,外部不可达;tests/example.test.ts:只导入../index(入口文件),针对公开函数做断言。
明确告诉用户这是一个可复制或删除的起始模板。
验收:示例包存在,行为通过根目录入口暴露,impl藏在子文件夹中。
第 6 步:证明规则真的会咬人
这是整个技能的完成标准:一个在违规时不报错的配置毫无价值。操作分三步:
- 运行
lint:boundaries,干净示例必须通过; - 临时在
tests/example.test.ts里加一个深导入(例如import { thing } from "../lib/impl"),再次运行lint:boundaries,必须以tests-through-entrypoints失败; - 撤销深导入,再运行一次,必须通过。
验收:观察到了 通过 → 深导入失败 → 再通过 的全过程。若第 2 步没有失败,说明规则没有正确接线,必须修复后才能结束。
第 7 步:记录约定并让 Agent 能发现它
在packages 文件夹内(<packages-root>/README.md,放在它所管辖的包旁边)写一个README.md,覆盖:src/packages/<name>/的布局(根目录是入口、lib/是实现、tests/是测试)、"只通过包的入口文件(根目录文件)导入"、以及如何运行lint:boundaries。明确反对 barrel 文件:宁可暴露多个小入口,也不要通过一个 index 重新导出整棵子树。内容保持在"复制即用代码片段 + 四条规则各一段"的篇幅。
然后从仓库的 Agent 指令文件(存在CLAUDE.md就用它,否则用AGENTS.md,两者都没有就新建AGENTS.md)中加一个上下文指针。一行就够,例如:Packages are deep modules: see [src/packages/README.md](https://link.gitcode.com/i/13e81aed4c1ce9bd479dca9b07ef04fb) before adding or importing one.这就是让 Agent 主动发现边界规则、而不是撞上它才后悔的关键一步。
验收:<packages-root>/README.md存在且反对 barrel,仓库的CLAUDE.md/AGENTS.md链接到了它。
配置文件逐行拆解:正则如何区分"内外"
仓库自带的 dependency-cruiser.config.cjs 是整个方案的引擎,值得逐段理解。
PACKAGES_ROOT 与派生正则
/** Where packages live. One immediate child dir per package (flat, no nesting). */ const PACKAGES_ROOT = "src/packages"; // --- derived patterns (no need to edit) ------------------------------------- const R = PACKAGES_ROOT; /** * A package's private internals: anything nested inside a package subfolder. * The package's root files are its entry points and are NOT matched here: * they stay importable from outside. */ const PACKAGE_INTERNALS = `^${R}/[^/]+/[^/]+/`;唯一的编辑点是PACKAGES_ROOT。PACKAGE_INTERNALS这个正则表达的就是深度决定公私的核心哲学:
^src/packages/锚定包根目录;[^/]+匹配第一个目录层级,即包名;- 第二个
[^/]+匹配包内的第一层子文件夹(如lib、tests); - 结尾的
/匹配子文件夹下的内容。
因此:凡是匹配PACKAGE_INTERNALS的就是私有内部实现;而包根目录文件(如index.ts)由于后面没有第二层目录,不匹配该模式,从而保持对外可导入。
五条 forbidden 规则逐一解读
entrypoint-boundary-from-app(应用代码只能走入口):from: { pathNot: `^${R}/` }, // 导入方不在任何包内 to: { path: PACKAGE_INTERNALS },任何位于包树之外的文件,不得导入任何包内部。
entrypoint-boundary-across-packages(跨包只能走入口,包内自由):from: { path: `^${R}/([^/]+)/`, pathNot: `^${R}/[^/]+/tests/` }, // 导入方在包 $1 内且非测试 to: { path: PACKAGE_INTERNALS, pathNot: `^${R}/$1/`, // 同一包 → 包内自由 },这里的关键是 dependency-cruiser 的组匹配反向引用
$1:from的捕获组捕获了导入方所属的包名,to.pathNot用它放行"导入自己包内部"的情况。正如技能 Notes 所强调的:这个$1反向引用正是"自己人进得去、外人进不来"的机制所在,不要把它拆散成逐包的手写规则。tests-through-entrypoints(测试同样走入口):from: { path: `^${R}/([^/]+)/tests/` }, // 测试文件,属于包 $1 to: { path: PACKAGE_INTERNALS, pathNot: `^${R}/$1/tests/`, // 自己的 tests/ fixtures → 允许 },测试可以导入任意包的入口文件、以及自己
tests/目录下的 fixtures,但连自己包的lib/都不许深导入——这与 codebase-design 的"接口即测试面(the interface is the test surface)"原则一脉相承:调用方和测试跨越同一条缝,想测试接口背后的东西,说明模块的形状可能错了。tests-folder-is-private(tests 文件夹只对测试开放):from: { pathNot: `^${R}/[^/]+/tests/` }, // 导入方不是测试 to: { path: `^${R}/[^/]+/tests/` },防止业务代码顺手 import 测试 fixtures,堵住"测试代码泄漏进生产路径"的口子。
no-circular(禁环):from: {}, to: { circular: true },若只想限制包内出现环,可在注释提示下把作用域收窄到
^${R}/。
options 与分层占位
options: { doNotFollow: { path: "node_modules" }, tsConfig: { fileName: "tsconfig.json" }, enhancedResolveOptions: { extensions: [".ts", ".tsx", ".js", ".jsx", ".json"], }, },doNotFollow:跳过node_modules,避免噪音与误报;tsConfig:让 dependency-cruiser 使用tsconfig.json做模块解析;enhancedResolveOptions.extensions:声明参与解析的扩展名集合。
配置文件末尾还预留了**分层(layering)**的注释占位。技能明确区分两个正交的关注点:**接口隐藏(interface-hiding)**控制"怎么导入"(必须走入口),分层控制"哪个包可以依赖哪个"。仓库当前把分层留作注释模板,例如:
// { // name: "ui-may-not-depend-on-billing", // severity: "error", // from: { path: `^${R}/ui/` }, // to: { path: `^${R}/billing/` }, // },需要时可自行取消注释并填入真实的包名。
三条重要设计约束
技能 Notes 部分点明了三个容易忽略的设计决策:
公开 vs 私有由"深度"决定,而非枚举:包根目录文件是入口,任何子文件夹内容都是私有的。惯用的子文件夹是
lib/(实现)与tests/,但规则并不硬编码它们:任何子文件夹都是私有的,所以新增文件夹永远不需要改配置;新增入口也只需添加一个根目录文件,无需 barrel。包是扁平(flat)的:根目录下只有一层直接子目录即一个包。包的内部可以任意嵌套多深,但一个包内部不能再包含另一个包。
用
.cjs而非.js:这样即使仓库是"type": "module",配置里的module.exports也能正常工作。同理,不要使用路径别名去绕过边界——第 4 步明确要求"不要改动 tsconfig,也不要添加 path aliases",否则边界的意义会被别名击穿。
与深模块设计体系的衔接
setup-ts-deep-modules 是本仓库"深模块"体系中的落地工具,与设计侧的能力形成闭环:
- 词汇与判断标准来自 codebase-design,它回答了"什么样的模块算深、缝该放在哪里";
- 深化方法论在 DEEPENING.md:按依赖类别(进程内、本地可替换、远程但自有的 Ports & Adapters、真正的外部 Mock)决定如何跨缝测试,并强调"测试应跨越接口断言可观察结果,而不是内部状态"——这正是 setup-ts-deep-modules 让测试只能走入口的深层动机;
- 接口的多种候选形态探索见 DESIGN-IT-TWICE.md:并行设计若干"激进不同"的接口,再按深度、局部性与缝的位置对比取舍。
值得说明的是,该技能目前位于仓库的in-progress/(beta)桶中,根据 in-progress/README.md 的说明,处于 beta 的技能不会进入插件与顶层 README,可以按npx skills@latest add mattpocock/skills --skill=setup-ts-deep-modules的方式单独安装。其配套的 Agent 声明 agents/openai.yaml 将allow_implicit_invocation设为false,disable-model-invocation: true,表明它是用户主动调用的技能,而非模型可自行触发的隐式技能。
落地后你应该拥有什么
完成七步之后,仓库将获得四项可验证的成果:
- 一个可运行的边界检查:
lint:boundaries与 typecheck 同命令执行,任何深导入、跨包触底、测试直取内部、依赖成环都会让 CI 红牌; - 一个统一的包形态:根目录入口 +
lib/实现 +tests/测试,任何新包都能照抄 example 模板; - 一份面向未来的约定文档:
<packages-root>/README.md明确反对 barrel、倡导多入口; - 一条 Agent 可发现的路径:
CLAUDE.md/AGENTS.md中的一行指针,让后续所有编码 Agent 在动手前先读到边界规则,而不是在违规报错后才被迫理解它。
最终,这套配置让"深度优先"从设计口号变成持续集成的硬约束:接口即边界,边界即 CI,CI 即文化。
【免费下载链接】skillsSkills for Real Engineers. Straight from my .agents directory.项目地址: https://gitcode.com/GitHub_Trending/skills13/skills
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考