setup-ts-deep-modules
Original:🇺🇸 English
Translated
1 scripts
Wire dependency-cruiser into a TypeScript repo so each package is a deep module — implementation hidden in subfolders, reachable only through its entry-point files. User-invoked.
1.4kinstalls
Sourcemattpocock/skills
Added on
NPX Install
npx skill4agent add mattpocock/skills setup-ts-deep-modulesTags
Translated version includes tags in frontmatterSKILL.md Content
View Translation Comparison →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), run the skill — use its language throughout.
/codebase-designThe 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 . By convention implementation lives in and tests in , 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.
index.tslib/tests/Four rules, all :
error- 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.
- Intra-package freedom — a package's own files import each other freely.
- Tests through the entry points — files under may import any package's entry points and their own
<pkg>/tests/fixtures, but never any package's subfolder internals (not even their own). Integration tests across packages are fine; deep imports are not.tests/ - 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 (, , ) instead of funnelling everything through one giant . Barrel files that re-export a whole subtree are discouraged — keep entry points small and hide implementation in subfolders.
index.tsclient.tsserver.tsindex.tsLayering (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,
pnpm-lock.yaml→ yarn,yarn.lock→ bun, else npm. Use it for every command below (bun.lockb/pnpm/yarn/npm run).bunx - Packages root — if exists use
src/, elsesrc/packages. Confirm the choice with the user if the repo already has a different obvious convention.packages - Existing config — check for a file. If one exists, do not overwrite it: merge the four rules and the options in, and tell the user what you added.
.dependency-cruiser.*
Done when: package manager, packages root, and existing-config status are all known.
2. Install dependency-cruiser
Install as a devDependency with the detected package manager.
dependency-cruiserDone when: is in .
dependency-cruiserdevDependencies3. Write the config
Copy to the repo root as . Set to the root detected in step 1. The rules are path-depth based and extension-agnostic, so nothing else needs adapting.
dependency-cruiser.config.cjs.dependency-cruiser.cjsPACKAGES_ROOTDone when: exists with the correct , and the four forbidden rules are present.
.dependency-cruiser.cjsPACKAGES_ROOT4. Wire it into the checks
- Add a script:
lint:boundaries(ordepcruise <packages-root>).depcruise src - Fold it into the repo's umbrella check command — the one that already runs typecheck (e.g. a /
check/ciscript). Do not touchvalidateor add path aliases.tsconfig - If there is no umbrella script, add and tell the user to include it in CI.
lint:boundaries
Done when: exists and runs as part of the same command as typecheck.
lint:boundaries5. Scaffold the example package
Create a committed as a copy-me template:
<packages-root>/example/- — an entry point. Export one function that delegates to an internal file (so the package is visibly deep, not a pass-through).
index.ts - — an internal file in a subfolder, imported by
lib/impl.ts, not reachable from outside.index.ts - — imports only
tests/example.test.ts(an entry point), and asserts against the public function.../index
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 in a subfolder.
impl6. Prove the rules bite
This is the completion criterion for the whole skill — a config that doesn't fail on a violation is worthless.
- Run . It must pass on the clean example.
lint:boundaries - Temporarily add a deep import to (e.g.
tests/example.test.ts). Runimport { thing } from "../lib/impl"again — it must fail withlint:boundaries.tests-through-entrypoints - Revert the deep import. Run once more — 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 — fix before finishing.
7. Document the convention
Write a in the packages folder () — next to the packages it governs — covering: the layout (entry points at the root, for implementation, for tests), "import only through a package's entry points (its root files)", and how to run . 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.
README.md<packages-root>/README.mdsrc/packages/<name>/lib/tests/lint:boundariesThen add a context pointer to it from the repo's agent-instructions file — if present, else (create if neither exists). One line is enough, e.g. This is what makes an agent discover the boundary rule instead of tripping over it.
CLAUDE.mdAGENTS.mdAGENTS.mdPackages are deep modules — see [src/packages/README.md](./src/packages/README.md) before adding or importing one.Done when: exists and discourages barrels, and the repo's / links to it.
<packages-root>/README.mdCLAUDE.mdAGENTS.mdNotes
- The config's 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.
$1 - 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 (implementation) and
lib/, 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.tests/ - 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 (not
.cjs) so the config's.jsworks even inmodule.exportsrepos."type": "module"