Skill Learning

setup-ts-deep-modules

1. Bilingual SKILL.md

左右滑动可直接切换英文版与中文版;两种语言按段落自动对齐。英文中的蓝色虚线词语可点击查看解释。共标记 16 处。

入口 skill:setup-ts-deep-modules左右滑动切换语言 · 段落位置自动对齐
# Setup TS Deep Modules
Make every package in this repo a **deep module**: a lot of behaviour behind a small interface. A package's is its **entry points** (the files at the package root), and everything in its subfolders is hidden. This skill installs [dependency-cruiser](https://github.com/sverweij/dependency-cruiser) and the rules that make the entry points , then .
For the vocabulary (deep module, interface, seam, depth), call the Skill tool with "codebase-design" and use its language throughout.
## The shape this enforces
``` src/packages/ <name>/ index.ts ← an entry point (public). Import this from outside. client.ts ← another entry point. Packages may expose SEVERAL. lib/ ← implementation: hidden from outside, free to import each other. tests/ ← co-located tests + fixtures (a subfolder, so private). ```
The is the package's **root files**, not one designated `index.ts`. By convention implementation lives in `lib/` and tests in `tests/`, giving every package the same two-folder shape. The rule itself is general, though: *anything* in *any* subfolder is private, so you never extend the config to add a folder.
Four rules, all `error`:
1. **Entry-point boundary**: code outside a package (app code or another package) may import only that package's entry points (its root files), never anything in its subfolders. 2. ****: a package's own files import each other freely. 3. **Tests through the entry points**: files under `<pkg>/tests/` may import any package's entry points and their own `tests/` fixtures, but never any package's subfolder internals (not even their own). Integration tests across packages are fine; . 4. **No cycles**: no dependency cycles.
**Entry points, not a barrel.** Because the is *every* root file, a package can expose several small entry points (`index.ts`, `client.ts`, `server.ts`) instead of `index.ts`. Barrel files that re-export a whole subtree are discouraged; keep entry points small and hide implementation in subfolders.
Layering (which packages may depend on which) is a *different* concern and is left as a commented stub in the config for this repo to fill in.
## Steps
### 1. Detect the environment
- **Package manager**: `pnpm-lock.yaml` → pnpm, `yarn.lock` → yarn, `bun.lockb` → bun, else npm. Use it for every command below (`pnpm`/`yarn`/`npm run`/`bunx`). - **Packages root**: if `src/` exists use `src/packages`, else `packages`. Confirm the choice with the user if the repo already has a different obvious convention. - **Existing config**: check for a `.dependency-cruiser.*` file. If one exists, do **not** overwrite it: merge the four rules and the options in, and tell the user what you added.
**Done when:** package manager, packages root, and existing-config status are all known.
### 2. Install dependency-cruiser
Install `dependency-cruiser` as a devDependency with the detected package manager.
**Done when:** `dependency-cruiser` is in `devDependencies`.
### 3. Write the config
Copy [`dependency-cruiser.config.cjs`](./dependency-cruiser.config.cjs) to the repo root as `.dependency-cruiser.cjs`. Set `PACKAGES_ROOT` to the root detected in step 1. The rules are , so nothing else needs adapting.
**Done when:** `.dependency-cruiser.cjs` exists with the correct `PACKAGES_ROOT`, and the four forbidden rules are present.
### 4. Wire it into the checks
- Add a `lint:boundaries` script: `depcruise <packages-root>` (or `depcruise src`). - Fold it into the repo's , the one that already runs typecheck (e.g. a `check` / `ci` / `validate` script). Do **not** touch `tsconfig` or add path aliases. - If there is no umbrella script, add `lint:boundaries` and tell the user to include it in CI.
**Done when:** `lint:boundaries` exists and runs as part of the same command as typecheck.
### 5. Scaffold the example package
Create a committed `<packages-root>/example/` as a :
- `index.ts` is an entry point. Export one function that delegates to an internal file (so the package is visibly *deep*, not a pass-through). - `lib/impl.ts`: an internal file in a **subfolder**, imported by `index.ts`, not reachable from outside. - `tests/example.test.ts` imports **only** `../index` (an entry point) and asserts against the public function.
Tell the user this is a starter template to copy or delete.
**Done when:** the example package exists, exposes its behaviour through a root entry point, and hides `impl` in a subfolder.
### 6. Prove the rules bite
This is the for the whole skill: a config that doesn't fail on a violation is worthless.
1. Run `lint:boundaries`. It must **pass** on the clean example. 2. Temporarily add a deep import to `tests/example.test.ts` (e.g. `import { thing } from "../lib/impl"`). Run `lint:boundaries` again; it must **fail** with `tests-through-entrypoints`. 3. Revert the deep import. Run once more, and it must **pass**.
**Done when:** you have observed a pass, then a fail on the deep import, then a pass again. If step 2 does not fail, the rules are not wired correctly, so fix before finishing.
### 7. Document the convention
Write a `README.md` **in the packages folder** (`<packages-root>/README.md`, next to the packages it governs) covering: the `src/packages/<name>/` layout (entry points at the root, `lib/` for implementation, `tests/` for tests), "import only through a package's entry points (its root files)", and how to run `lint:boundaries`. **Discourage barrel files** explicitly: expose several small entry points instead of re-exporting a whole subtree through one index. Keep it to the copy-me snippet plus the four rules in one paragraph each.
Then add a **** to it from the repo's agent-instructions file (`CLAUDE.md` if present, else `AGENTS.md`, creating `AGENTS.md` if neither exists). One line is enough, e.g. `Packages are deep modules: see [src/packages/README.md](./src/packages/README.md) before adding or importing one.` This is what makes an agent discover the boundary rule instead of .
**Done when:** `<packages-root>/README.md` exists and discourages barrels, and the repo's `CLAUDE.md`/`AGENTS.md` links to it.
## Notes
- The config's `$1` (dependency-cruiser's group matching) are what let a package reach its own internals while outsiders can't. . - Public vs private is decided by **depth**: a package's root files are entry points; anything in a subfolder is private. The conventional subfolders are `lib/` (implementation) and `tests/`, but the rule doesn't hardcode them: any subfolder is private, so a new folder never needs a config change. Adding an entry point is just adding a root file (no barrel). - Packages are **flat**: one tier of immediate children under the root. A package's internals may nest as deep as you like; a package may not contain another package. - Use `.cjs` (not `.js`) so the config's `module.exports` works even in `"type": "module"` repos.
# 设置 TypeScript 深模块
让此仓库中的每个包都成为一个**深模块(deep module)**:用小型接口封装大量行为。包的公开表面是它的**入口点(entry points)**(位于包根目录的文件),而子文件夹中的所有内容都被隐藏。此 skill 会安装 [dependency-cruiser](https://github.com/sverweij/dependency-cruiser) 以及让入口点成为唯一访问方式的规则,然后证明这些规则确实会拦截违规。
关于 deep module、interface、seam、depth 这些词汇,请用 “codebase-design” 调用 Skill 工具,并始终使用其中的语言体系。
## 此 skill 强制执行的结构
``` src/packages/ <name>/ index.ts ← 一个入口点(公开)。从包外部导入此文件。 client.ts ← 另一个入口点。包可以公开多个入口点。 lib/ ← 实现:对外隐藏,内部文件可自由互相导入。 tests/ ← 就近放置的测试和 fixture(属于子文件夹,因此是私有的)。 ```
公开表面是包的**根目录文件**,而不是某一个指定的 `index.ts`。按照约定,实现放在 `lib/`,测试放在 `tests/`,使每个包都采用相同的双文件夹结构。不过规则本身是通用的:*任何*子文件夹中的*任何*内容都是私有的,因此不必为了新增文件夹而扩展配置。
四条规则,严重级别全部为 `error`:
1. **入口点边界**:包外代码(应用代码或其他包)只能导入该包的入口点(根目录文件),绝不能导入其子文件夹中的内容。 2. **包内自由**:一个包自己的文件可以自由互相导入。 3. **测试通过入口点访问**:`<pkg>/tests/` 下的文件可以导入任意包的入口点和自身 `tests/` 中的 fixture,但绝不能导入任何包的子文件夹内部实现(即便是本包也不行)。允许跨包集成测试;不允许深层导入。 4. **禁止循环**:不允许依赖循环。
**使用入口点,而不是 barrel。** 由于公开表面是*每一个*根目录文件,一个包可以公开多个小型入口点(`index.ts`、`client.ts`、`server.ts`),而不必把所有内容都汇入一个巨大的 `index.ts`。不鼓励使用重新导出整棵子树的 barrel 文件;入口点应保持小巧,实现则隐藏在子文件夹中。
分层(哪些包可以依赖哪些包)是另一个*不同*的问题;配置中只为当前仓库留下一个注释形式的存根,由仓库自行补充。
## 步骤
### 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.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. 证明规则确实会拦截违规
这是整个 skill 的完成判据:一份面对违规却不失败的配置毫无价值。
1. 运行 `lint:boundaries`。干净的示例必须**通过**。 2. 临时在 `tests/example.test.ts` 中添加深层导入(例如 `import { thing } from "../lib/impl"`)。再次运行 `lint:boundaries`;它必须以 `tests-through-entrypoints` **失败**。 3. 撤销深层导入。再运行一次,它必须重新**通过**。
**完成条件:** 已经实际观察到先通过、深层导入时失败、撤销后再通过。如果步骤 2 没有失败,说明规则没有正确接入;必须修好后才能结束。
### 7. 记录这项约定
在**包文件夹内**编写 `README.md`(`<packages-root>/README.md`,与其管辖的包并列),内容包括:`src/packages/<name>/` 结构(根目录放入口点、`lib/` 放实现、`tests/` 放测试)、“只能通过包的入口点(根目录文件)导入”,以及如何运行 `lint:boundaries`。必须明确**不鼓励 barrel 文件**:应公开多个小入口点,而不是通过一个 index 重新导出整棵子树。内容保持精简:一个可复制片段,再为四条规则各写一段。
然后从仓库的 agent 指令文件添加一个指向它的**上下文指针(context pointer)**:若存在 `CLAUDE.md` 则使用它,否则使用 `AGENTS.md`;两者都不存在时创建 `AGENTS.md`。一行即可,例如:`Packages are deep modules: see [src/packages/README.md](./src/packages/README.md) before adding or importing one.` 正是这个指针让 agent 能发现边界规则,而不是先撞上规则才知道。
**完成条件:** `<packages-root>/README.md` 已存在并明确反对 barrel,且仓库的 `CLAUDE.md`/`AGENTS.md` 已链接到它。
## 说明
- 配置中的 `$1` 反向引用(dependency-cruiser 的分组匹配)让一个包可以访问自己的内部实现,同时阻止外部访问。不要把它们摊平成逐包规则。 - 公开与私有由**深度**决定:包的根目录文件是入口点;子文件夹中的任何内容都是私有的。约定的子文件夹是 `lib/`(实现)和 `tests/`,但规则并未硬编码它们:任何子文件夹都属于私有,因此新增文件夹无需修改配置。新增入口点只需添加一个根目录文件(无需 barrel)。 - 包结构是**扁平的**:包根目录下只有一层直接子包。包内部可以任意加深,但一个包不能包含另一个包。 - 使用 `.cjs`(而不是 `.js`),这样即使仓库设置了 `"type": "module"`,配置中的 `module.exports` 仍能工作。

Entry SHA-256: 29acca66ac99d4532e2a6f0370d8125d7cba1e87c9ac66573203c75189d3e6c7

2. Why This Skill Is Clever

一句话核心机制

把“深模块”从架构口号变成路径深度规则:包根文件是公开入口,所有子文件夹均为私有实现,再用一次通过、一次故意失败、一次恢复通过的实验完成验收。

触发与边界

信息架构 / 执行顺序

  1. 检测包管理器、packages root 和现有 dependency-cruiser 配置,先消除环境不确定性。
  2. 以 devDependency 安装工具;若已有配置则合并,绝不覆盖。
  3. 复制参数化配置,只调整 PACKAGES_ROOT,用路径深度统一定义公开与私有。
  4. 把 lint:boundaries 并入与 typecheck 相同的总检查入口,使本地与 CI 共用门禁。
  5. 创建一个真正“有深度”的示例包:根入口委托给 lib/,测试只穿过公开入口。
  6. 执行 pass → intentional fail → pass 的反向验证,证明违规会被预期规则名捕获。
  7. 在 packages 目录记录约定,并从 CLAUDE.md/AGENTS.md 添加轻量 context pointer。

最巧妙的设计点

1. 用路径深度定义边界,而不是枚举文件夹名

“anything in any subfolder is private”

Direct source: 公开/私有只取决于文件是否位于包根目录。新增 adapters/、generated/ 或其他内部目录时无需修改配置,避免规则随目录数量线性膨胀。

2. 多入口点替代巨型 barrel

“Entry points, not a barrel.”

Direct source: 它没有把 index.ts 神化为唯一入口,而允许 client.ts、server.ts 等多个小入口。接口仍然小且明确,同时避免一个 barrel 重导出整棵实现树、制造耦合与循环。

3. 用捕获组表达“本包自由、跨包受限”

“The config's $1 back-references”

Direct source + Supporting context: 配套配置通过 ^${R}/([^/]+)/ 捕获当前包,再用 pathNot: ^${R}/$1/ 排除同包目标。一组通用规则因此覆盖任意包,而不需要逐包配置。

4. 测试也必须像真实消费者一样走公开接口

“Tests through the entry points”

Direct source: 即便测试与实现同包,也不能直接导入 lib/。这会迫使测试验证可承诺的公开行为,防止内部重构被脆弱白盒测试锁死;同时保留对自身 tests/ fixture 的访问。

5. 完成定义包含可控的失败

“a config that doesn't fail on a violation is worthless”

Direct source: 任务不是“配置文件存在”就结束,而是必须观察干净样例通过、临时深层导入以 tests-through-entrypoints 失败、撤销后再次通过。负向测试验证了规则接线、作用域和错误身份。

6. 让约束同时可执行、可发现

“context pointer”

Direct source: dependency-cruiser 负责硬门禁,packages README 解释结构,agent 指令文件只放一行导航。三层分别解决执行、理解和发现问题,避免把完整规范永久塞入上下文。

防失败机制与 trade-off

可迁移原则

  1. 用一个稳定的结构属性表达规则,优于不断枚举例外和目录名称。
  2. 允许多个小入口,不要把“统一入口”误解成“唯一巨型文件”。
  3. 通用规则应捕获身份并参数化复用,避免逐模块复制配置。
  4. 每项硬约束都要有反向验收:主动制造违规,并检查它以预期原因失败。
  5. 把门禁、解释和导航分层:机器执行规则,局部文档讲清规则,常驻上下文只负责指路。

今日实践题

如果你的仓库新增 src/packages/payments/generated/,按这套设计是否需要修改 dependency-cruiser 配置?请分别从“路径深度规则”和“公开入口设计”解释原因。

Evidence note

双语原文、步骤、完成条件、边界语义和 pass/fail/pass 验收来自选中的 setup-ts-deep-modules/SKILL.md。dependency-cruiser.config.cjs 只作为 Supporting context,用于核对 $1、五个具体 forbidden 条目和配置假设;agents/openai.yaml 与 bucket README 只佐证用户显式调用及 beta 状态。测试取舍、monorepo 迁移风险和分层局限属于 Interpretation。

Source & provenance
Repository path
skills/in-progress/setup-ts-deep-modules/SKILL.md
Generated
2026-08-28 16:55 Asia/Shanghai
Upstream commit
6654f6b60cd9d5be8b54c6fafe44346dabeb3b76
Source
View on GitHub