Rename to setup-ts-deep-modules; entry-points model over single barrel

- Rename skill to setup-ts-deep-modules (it's TypeScript-specific).
- A package's public surface is now its ENTRY POINTS — every file at the
  package root — not one designated index.ts. Implementation lives in
  subfolders and is private. This is depth-based and extension-agnostic.
- Packages can expose several small entry points instead of funnelling
  everything through one giant barrel index; barrels are discouraged, and
  the generated repo docs (step 7) say so explicitly.
- Rules renamed accordingly: entrypoint-boundary-from-app,
  entrypoint-boundary-across-packages, tests-through-entrypoints.
- Example package now hides impl in a lib/ subfolder.

Re-validated against dependency-cruiser 16: multiple entry points and
cross-package entry imports pass; deep imports into subfolders fail with
the expected rule from app code, other packages, and tests.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Matt Pocock
2026-07-10 10:10:02 +01:00
co-authored by Claude Opus 4.8
parent f947f93b92
commit 3ea30afc68
3 changed files with 49 additions and 45 deletions
+1 -1
View File
@@ -8,4 +8,4 @@ Skills that are still being developed. They're not ready to ship — expect roug
- **[writing-fragments](./writing-fragments/SKILL.md)** — Grilling session that mines you for fragments — heterogeneous nuggets of writing — and appends them to a single document as raw material for a future article.
- **[writing-shape](./writing-shape/SKILL.md)** — Take a markdown file of raw material and shape it into an article paragraph by paragraph, arguing format choices at each step.
- **[claude-handoff](./claude-handoff/SKILL.md)** — Hand the current conversation off to a fresh background agent that picks up the work immediately, seeded with a handoff summary via `claude --bg`. User-invoked.
- **[setup-deep-modules](./setup-deep-modules/SKILL.md)** — Wire dependency-cruiser into a TypeScript repo so each package is a deep module — everything hidden behind its `index.ts`, tests exercising it through that interface. User-invoked.
- **[setup-ts-deep-modules](./setup-ts-deep-modules/SKILL.md)** — 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, tests exercising it through those. User-invoked.
@@ -1,12 +1,12 @@
---
name: setup-deep-modules
description: Wire dependency-cruiser into a TypeScript repo so each package is a deep module — everything hidden behind its index. User-invoked.
name: setup-ts-deep-modules
description: 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.
disable-model-invocation: true
---
# Setup Deep Modules
# Setup TS Deep Modules
Make every package in this repo a **deep module**: a lot of behaviour behind a small interface. Each package under the packages root exposes exactly one interface — its `index.ts` — and everything else is hidden. This skill installs [dependency-cruiser](https://github.com/sverweij/dependency-cruiser) and the rules that make the index the only way in, then proves the rules bite.
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](https://github.com/sverweij/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 `/codebase-design` skill — use its language throughout.
@@ -15,18 +15,23 @@ For the vocabulary (deep module, interface, seam, depth), run the `/codebase-des
```
src/packages/
<name>/
index.ts ← the ONLY public interface. Outsiders import this and nothing else.
<internals> ← free to import each other; invisible to the outside world.
tests/ ← co-located tests + fixtures. Reach the package through index only.
index.ts ← an entry point (public). Import this from outside.
client.ts ← another entry point. Packages may expose SEVERAL.
<subfolder>/ ← internals: 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`. Implementation lives in subfolders.
Four rules, all `error`:
1. **Index boundary** — code outside a package (app code or another package) may import only `<pkg>/index.ts`, never anything deeper.
2. **Intra-package freedom** — a package's own internals import each other freely.
3. **Tests through the index** — files under `<pkg>/tests/` may import any package's `index.ts` and their own `tests/` fixtures, but never any package's internals (not even their own). Integration tests across packages are fine; deep imports are not.
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.
## Steps
@@ -47,7 +52,7 @@ Install `dependency-cruiser` as a devDependency with the detected package manage
### 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. If the repo is JavaScript rather than TypeScript, change the index pattern and extensions from `ts`/`tsx` to `js`/`jsx`.
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 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.
@@ -63,34 +68,35 @@ Copy [`dependency-cruiser.config.cjs`](./dependency-cruiser.config.cjs) to the r
Create a committed `<packages-root>/example/` as a copy-me template:
- `index.ts`the interface. Export one function that delegates to an internal file (so the package is visibly *deep*, not a pass-through).
- an internal file (e.g. `impl.ts`) — imported by `index.ts`, not exported.
- `tests/example.test.ts` — imports **only** `../index`, and asserts against the public function.
- `index.ts`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 and imports only through its own index.
**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 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 "../impl"`). Run `lint:boundaries` again — it must **fail** with `tests-only-through-index`.
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 — 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 `README.md` **in the packages folder** (`<packages-root>/README.md`) — next to the packages it governs — covering: the `src/packages/<name>/` layout, "import only through `index.ts`", where tests live, and how to run `lint:boundaries`. Keep it to the copy-me snippet plus the four rules in one paragraph each.
Write a `README.md` **in the packages folder** (`<packages-root>/README.md`) — next to the packages it governs — covering: the `src/packages/<name>/` layout, "import only through a package's entry points (its root files)", that implementation lives in subfolders, where tests live, 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` (create `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 the repo's `CLAUDE.md`/`AGENTS.md` links to it.
**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` 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. Adding an entry point is just adding a root file — no barrel, no config change.
- 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.
@@ -2,9 +2,10 @@
// Deep-module enforcement for dependency-cruiser.
//
// Each package under the packages root is a DEEP MODULE: a lot of behaviour
// behind a small interface (its `index.ts`). The rules below make the index the
// only way in — internals stay hidden, and tests exercise the package through
// the same interface everyone else does.
// behind a small interface. A package's PUBLIC SURFACE is its ENTRY POINTS —
// the files at the package root. Implementation lives in SUBFOLDERS and is
// private. A package may expose several small entry points (index.ts,
// client.ts, server.ts, …) — prefer that over one giant barrel index.
//
// The only thing you should ever need to edit here is PACKAGES_ROOT.
@@ -13,49 +14,45 @@ const PACKAGES_ROOT = "src/packages";
// --- derived patterns (no need to edit) -------------------------------------
const R = PACKAGES_ROOT;
/** Any file that lives inside some package's internals or subfolders. */
const INSIDE_A_PACKAGE = `^${R}/[^/]+/.+`;
/** A package's public interface — the only legal way in. */
const ANY_INDEX = `^${R}/[^/]+/index\\.(ts|tsx)$`;
/**
* 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}/[^/]+/[^/]+/`;
/** @type {import('dependency-cruiser').IConfiguration} */
module.exports = {
forbidden: [
{
name: "index-boundary-from-app",
name: "entrypoint-boundary-from-app",
comment:
"App/root code may reach a package only through its index — never a deep import.",
"App/root code may import a package's entry points (its root files), but nothing inside its subfolders.",
severity: "error",
from: { pathNot: `^${R}/` }, // importer is NOT inside any package
to: { path: INSIDE_A_PACKAGE, pathNot: ANY_INDEX },
to: { path: PACKAGE_INTERNALS },
},
{
name: "index-boundary-across-packages",
name: "entrypoint-boundary-across-packages",
comment:
"A package's internals may import each other freely, but may reach OTHER packages only through their index.",
"A package's own files import each other freely, but may reach OTHER packages only through their entry points — never their internals.",
severity: "error",
// importer is inside a package ($1), but is not a test file
from: { path: `^${R}/([^/]+)/`, pathNot: `^${R}/[^/]+/tests/` },
to: {
path: INSIDE_A_PACKAGE,
pathNot: [
`^${R}/$1/`, // same package → intra-package freedom
ANY_INDEX, // any package's index → allowed
],
path: PACKAGE_INTERNALS,
pathNot: `^${R}/$1/`, // same package → intra-package freedom
},
},
{
name: "tests-only-through-index",
name: "tests-through-entrypoints",
comment:
"A package's tests exercise it through the index like everyone else: they may import any package's index and their own tests/ fixtures, but never any package's internals — not even their own.",
"A package's tests exercise it through its entry points like everyone else: they may import any package's entry points and their own tests/ fixtures, but never any package's internals — not even their own.",
severity: "error",
from: { path: `^${R}/([^/]+)/tests/` }, // a test file, in package $1
to: {
path: INSIDE_A_PACKAGE,
pathNot: [
`^${R}/$1/tests/`, // own tests/ fixtures → allowed
ANY_INDEX, // any package's index → allowed (integration tests are fine)
],
path: PACKAGE_INTERNALS,
pathNot: `^${R}/$1/tests/`, // own tests/ fixtures → allowed
},
},
{
@@ -75,8 +72,9 @@ module.exports = {
},
// --- Layering (optional, off by default) ----------------------------------
// Interface-hiding controls HOW you import (through the index). Layering
// controls WHICH packages may depend on which. Add your own rules here, e.g.:
// Interface-hiding controls HOW you import (through the entry points).
// Layering controls WHICH packages may depend on which. Add your own rules
// here, e.g.:
//
// {
// name: "ui-may-not-depend-on-billing",