再学 AI
第 30 课 / setup-ts-deep-modules
实验性阅读 → 对照 → 问答 → 场景

让模块访问约定变成可检查的规则

实验性技能,用依赖检查器限制 TypeScript 包的外部访问,并实际证明违规会被拦住。

还不清楚 Skill、Agent、安装和调用?先读 从零开始的6节入门课

实验性技能

作者将它放在 in-progress,未作为稳定插件内容推广。先理解原理,实际使用前核实版本和依赖。

在关系图中查看 setup-ts-deep-modules 与其他技能的关联 →

先把必要的概念讲清楚

这项实验性技能把模块边界变成自动检查。它帮助你理解一个关键工程方法:规则写在文档里还不够,应该能故意制造违规并观察工具确实拦住它。

下面是老师补充的入门说明;原作者的要求保留在中英对照正文中。所有例子均为帮助理解而构造的教学情境。

package / dependency|代码包与依赖

package 是可以统一安装、导入或管理的一组代码;dependency 是当前程序需要的其他代码。开发依赖常用于检查和构建。安装了一个包只说明代码可取得,还要配置并调用,才会实际参与工作。

deep module|深模块

用相对简单的公开接口封装较多内部复杂性。调用者只说“为这节课生成复习安排”,模块内部处理规则、时间和重复项。深是接口与实现复杂性的关系,不是目录嵌套深,也不是函数越长越好。

interface / API|接口

使用者与一项能力交互时遵循的约定,包括可调用什么、传入什么、得到什么、失败怎样表示。接口可以是程序函数,也可以是网络请求。创建收藏接口接收用户和课程信息、返回收藏结果;它不需要向调用者暴露数据库表结构。这里的接口通常不是指页面外观。 本教材涉及更广义的“接口”时,还包括错误、调用顺序和约束等使用约定;不能把接口一概等同于网络 API。

CI|持续集成检查

把修改提交到共享流程时,自动运行测试、类型检查等约定检查,尽早发现集成问题。绿灯表示配置的检查通过,不代表所有需求都正确;红灯也可能由环境故障导致,应看具体证据。需要先检查“配置了什么”,才能解释绿灯的意义。

fixture / harness|测试样本与运行装置

fixture 是为复现或测试准备的已知输入和初始数据,例如固定课程清单。harness 是把代码、输入和检查串起来的运行装置,例如一次命令启动最小服务并断言结果。它们帮助每次在相同条件下比较,而不是凭上次页面看起来怎样判断。

读原文,理解每一步为什么这样做

左右内容按小节对应;窄屏先中文、后英文。两种语言均完整展示,对应讲解紧接在小节之后。译文传达原文要求;老师讲解补充概念、原因、例子与适用边界。

译注

原文允许缺少总检查脚本时仅提示用户纳入 CI,但紧接着的完成标准仍要求与类型检查共用命令;两处原意均保留。

中文译文English · 英文原文
中文译文
name: setup-ts-deep-modules
description: "为 TypeScript 仓库接入 dependency-cruiser,让每个包成为深模块:实现隐藏在子目录中,外部只能通过入口文件访问。由用户调用。"
disable-model-invocation: true
English · 英文原文
name: setup-ts-deep-modules
description: Wire dependency-cruiser into a TypeScript repo so each package is a deep module, with implementation hidden in subfolders and reachable only through its entry-point files. User-invoked.
disable-model-invocation: true
中文译文

为 TypeScript 项目设置模块导入规则

让各软件包通过较小接口提供较多行为。包根目录的文件是公开入口,子目录内容隐藏。安装 dependency-cruiser,限制外部只能经入口使用,并证明违规会失败。

调用 codebase-design 获取共同设计词汇,全文使用这些概念。

English · 英文原文

Setup TS Deep Modules

Make every package in this repo a deep module: a lot of behaviour behind a small interface. A package's public surface is its entry points (the files at the package root), and everything in its subfolders is hidden. This skill installs dependency-cruiser and the rules that make the entry points the only way in, then proves the rules bite.

For the vocabulary (deep module, interface, seam, depth), call the Skill tool with "codebase-design" and use its language throughout.

中文译文

目录规则

src/packages/<name>/ 为例,根目录 index.tsclient.ts 都可公开;lib/ 保存实现,tests/ 保存测试与数据,二者都对包外私有。

公开的是根目录所有文件,不只一个 index。lib 和 tests 是推荐名称,规则实际限制所有子目录,因此新增目录不需要另改配置。

四条规则都以 error 报错:

  1. 包外应用或其他包,只能导入根目录入口,不能深入子目录。
  2. 同包内部实现可以自由互相导入。
  3. tests 中的文件可以导入各包入口以及自己 tests 中的数据,但不能导入任何包的内部实现,连本包也不例外。可以做跨包集成测试。
  4. 不允许依赖形成循环。

多个小入口优于一个巨大总导出文件。可以分别公开 index、client、server,而不要将整个子树重新导出。哪些包能依赖哪些包属于另一种分层规则,配置留有注释供项目补充。

English · 英文原文

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 public surface 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. Intra-package freedom: 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; deep imports are not.
  4. No cycles: no dependency cycles.

Entry points, not a barrel. Because the public surface is every root file, a package can expose several small entry points (index.ts, client.ts, server.ts) instead of funnelling everything through one giant 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.

老师讲解 · 对应上方原文 · 含教学举例

公共入口与私有实现,按目录深度区分

每个包根目录的文件都是可供外部导入的入口,子目录中是内部实现。index.ts、client.ts 可以分别公开不同能力,不必把整包都塞入一个巨大 index。

例如课程包提供查询接口,内部 lib 保存规则与存储细节。其他包若直接导入 lib 某个文件,就依赖了私有结构,以后重排目录会连带破坏调用者。

这里的“私有”主要是依赖检查约束,不是数据保密或运行时访问控制。不能因为某文件放子目录,就认为浏览器或攻击者绝对不能读取它。

中文译文

配置步骤

English · 英文原文

Steps

中文译文
1. 识别环境

通过锁文件选择 pnpm、yarn、bun,否则 npm,后续统一采用该工具。有 src 时包根目录用 src/packages,否则 packages;现有明显不同约定时先确认。

检查 .dependency-cruiser.*。已有就合并四条规则和选项,不覆盖,并说明新增内容。完成时应知道包管理工具、包目录和已有配置情况。

English · 英文原文
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. 安装 dependency-cruiser

作为开发依赖安装。完成时 devDependencies 中应包含它。

English · 英文原文
2. Install dependency-cruiser

Install dependency-cruiser as a devDependency with the detected package manager.

Done when: dependency-cruiser is in devDependencies.

中文译文
3. 写配置

dependency-cruiser.config.cjs 放到根目录,命名 .dependency-cruiser.cjs,设定正确 PACKAGES_ROOT。规则按路径深度判断,与扩展名无关。完成时配置及四条规则都存在。

English · 英文原文
3. Write the config

Copy 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 path-depth based and extension-agnostic, so nothing else needs adapting.

Done when: .dependency-cruiser.cjs exists with the correct PACKAGES_ROOT, and the four forbidden rules are present.

老师讲解 · 对应上方原文 · 含教学举例

安装检查器和让检查器生效是两步

dependency-cruiser 分析模块依赖,配置规则规定哪些导入关系违规。安装开发依赖只让工具可用,还要写配置、设置包根目录,并接入检查命令。

已有配置不能整体覆盖,要合入所需规则并了解变化。路径中的分组引用使每个包可访问自己的内部,而外部不可访问;这比逐包复制一套规则更容易维护。

测试原则上通过公开入口验证行为,可以使用自己的测试夹具,但不应直接进入私有实现。跨包测试可以存在,只要仍从各包公开边界进入。

中文译文
4. 接入检查

添加 lint:boundaries,执行 depcruise <packages-root>depcruise src。加入原本执行类型检查的 check、ci 或 validate 等总命令,不修改 tsconfig,不添加路径别名。

没有总命令时先添加脚本,告诉用户需要接入 CI。目标是让它与类型检查通过同一流程执行。

English · 英文原文
4. Wire it into the checks
  • Add a lint:boundaries script: depcruise <packages-root> (or depcruise src).
  • Fold it into the repo's umbrella check command, 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. 创建示例包

提交 <packages-root>/example/ 作为可复制模板。index.ts 导出函数并调用内部实现;lib/impl.ts 放真实内部文件;tests/example.test.ts 只导入 ../index 并验证公开行为。

说明模板可复制或删除。完成时入口公开行为,内部实现在子目录隐藏。

English · 英文原文
5. Scaffold the example package

Create a committed <packages-root>/example/ as a copy-me template:

  • 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. 实际证明规则有效

这是整个技能的关键完成条件:

  1. 合法示例运行 lint:boundaries 必须通过。
  2. 临时在测试加入 import { thing } from "../lib/impl" 这样的内部导入,再运行,必须因 tests-through-entrypoints 失败。
  3. 撤回违规导入,再运行,必须通过。

必须观察到通过、违规失败、恢复通过。若第 2 步没有失败,就修复配置,不可宣称完成。

English · 英文原文
6. Prove the rules bite

This is the completion criterion 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.

老师讲解 · 对应上方原文 · 含教学举例

通过—失败—通过,验证护栏真的在工作

先运行干净示例应通过;再故意加入测试对 lib 内部文件的导入,检查必须因指定规则失败;撤销违规后应再次通过。这是测试“检查器本身是否正确接线”。

只有第一次绿灯不够:也许命令没扫描目标目录,或者规则根本未加载。人为违规却仍绿灯,就说明护栏没有发挥作用。

这项方法可以迁移到其他工程规则:权限检查、数据校验、发布门禁,都应有能够证明拒绝路径有效的例子,不能只检验成功路径。

检查文件存在,与违规真的被拦截,差在哪里?

假设出题包公开 index.ts,内部解析结果放 lib/parse-result.ts。页面应调用公开入口。如果页面直接依赖内部文件,内部重组就会影响页面。

规则希望控制这种依赖,因此需要故意增加一次违规导入,看工具是否报出预期错误;撤回后恢复通过。否则扫描目录或命令接入可能根本没生效。

测试也走入口,是为了检查调用者观察到的行为。内部函数换名但输出仍正确时,测试不应无故失败。

这只能约束依赖路径,不能自动证明模块有深度。一个入口只是转发到另一个文件,也不一定真正集中承担复杂性。目录检查与设计质量需要分别判断。

中文译文
7. 记录约定

在包根目录写 README,说明根文件为入口、lib 为实现、tests 为测试,以及如何运行检查。明确不鼓励总导出整个子树,而采用多个小入口。保留一个可复制布局,加上四条规则各一段说明。

从 CLAUDE.md 引用它;没有时用 AGENTS.md,两者都没有则创建 AGENTS.md。一句话说明添加或导入包之前应读这份规则,让 Agent 提前知道约束。

完成时,包目录 README 存在、说明总导出问题,并被 Agent 入口文档链接。

English · 英文原文
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 context pointer 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 tripping over it.

Done when: <packages-root>/README.md exists and discourages barrels, and the repo's CLAUDE.md/AGENTS.md links to it.

老师讲解 · 对应上方原文 · 含教学举例

让 Agent 在犯错前知道边界

在包目录写就近 README,并从 Agent 指令文件加一行触发指引。以后创建或导入包时,Agent 能先知道约定,而不是反复撞上检查失败。

barrel 是汇总并重新导出许多成员的文件。作者不鼓励巨大 barrel,主张多个小入口;这是一种边界设计选择,不是 TypeScript 禁止使用 index.ts。

原文总检查脚本的例外与完成标准存在不完全一致之处,译注已保留。实际任务要明确检查如何被运行,不能把“用户以后接入 CI”报告成“已接入”。

中文译文

补充说明

  • 配置的 $1 分组引用让包可访问自己内部,而外部不能。不要拆成逐包手写规则。
  • 根文件公开,所有子目录私有。新增子目录无需改配置,新增公开入口只需添加根文件。
  • 各包直接位于包根目录,保持单层;内部可以任意嵌套,但包不能再包含另一个包。
  • 使用 .cjs,使 module.exports 在声明 type: module 的项目里仍能运行。
English · 英文原文

Notes

  • The config's $1 back-references (dependency-cruiser's group matching) are what let a package reach its own internals while outsiders can't. Don't flatten them into separate per-package rules.
  • 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.

原作者:Matt Pocock · 中文翻译为非官方译本

来源:skills/in-progress/setup-ts-deep-modules/SKILL.md ↗

固定版本:3cca18b368ae95cdbdebbff572ccafa662551015

先作答,再看参考思路

Q1 · 理解

用自己的话说明:它解决什么问题,完成后会留下什么?

请各用一句话回答。若它只做规划或解释,不要把“已开发”“已部署”写成产物。

Q2 · 判断

“入口文件可以有多个”为什么不等于“内部文件都公开”?

我已思考,查看参考思路

公开与私有由目录深度决定,根文件承担入口职责;子目录仍由规则保护。入口应小而明确,不是全部转导出。

Q3 · 追问

原文中哪条要求在你的环境下可能不成立?

说出具体一句及其前提,例如工具不可用、资料缺失、已有项目约定冲突,或它只是作者偏好。把你的答案带回课堂,我们据此继续讨论。

课堂回传格式:第 30 课 / 我的理解 / Q2 回答 / 仍不理解的原句。这里是阅读教材;实时问答在我们的对话中进行。

把方法放进一个具体情境

教学案例:Agent 直接导入其他模块的内部解析函数,短期省事,后续内部重构却破坏调用方。规则可以让这类耦合在提交前可见。

边界与容易误读的地方

规则要适配现有结构,不能为了套模板重排整个仓库。示例包与检查通过也不能证明全部架构良好。

讨论后再实践:先判断上述情境是否适用,再选择真实任务。现在无需安装、运行命令或修改现有项目。

关联阅读